Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635afe2d1e |
@@ -1,18 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.claude
|
|
||||||
.svelte-kit
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
target
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.log
|
|
||||||
coverage
|
|
||||||
src-tauri/gen
|
|
||||||
src-tauri/target
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
name: '🏗️ Build and Test JellyTau'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-host-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: |
|
|
||||||
bunx svelte-kit sync
|
|
||||||
bun run test
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo test
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build Android APK
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
NDK_VERSION: 27.0.11902837
|
|
||||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-android-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Ensure Android NDK
|
|
||||||
run: |
|
|
||||||
if [ ! -d "$NDK_HOME" ]; then
|
|
||||||
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
|
|
||||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
|
|
||||||
fi
|
|
||||||
echo "Using NDK at $NDK_HOME"
|
|
||||||
ls "$NDK_HOME"
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
echo "" | bunx tauri android init
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Android APK
|
|
||||||
id: build
|
|
||||||
run: |
|
|
||||||
mkdir -p artifacts
|
|
||||||
bun run tauri android build --apk true --target aarch64
|
|
||||||
|
|
||||||
# Find the generated APK file
|
|
||||||
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-apk
|
|
||||||
path: ${{ steps.build.outputs.artifact }}
|
|
||||||
retention-days: 30
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
name: Build & Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version to build (e.g., v1.0.0)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-host-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: |
|
|
||||||
bunx svelte-kit sync
|
|
||||||
bun run test --run
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: bun run test:rust
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Check TypeScript
|
|
||||||
run: bun run check
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-host-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Build for Linux
|
|
||||||
run: bun run tauri build
|
|
||||||
env:
|
|
||||||
TAURI_SKIP_UPDATER: true
|
|
||||||
|
|
||||||
- name: Prepare Linux artifacts
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/linux
|
|
||||||
# Copy AppImage
|
|
||||||
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
|
|
||||||
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
|
|
||||||
fi
|
|
||||||
# Copy .deb if built
|
|
||||||
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
|
|
||||||
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
|
|
||||||
fi
|
|
||||||
ls -lah dist/linux/
|
|
||||||
|
|
||||||
- name: Upload Linux build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: dist/linux/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
build-android:
|
|
||||||
name: Build Android
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-android-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Set app version from tag
|
|
||||||
run: |
|
|
||||||
REF="${GITHUB_REF#refs/tags/v}"
|
|
||||||
VERSION="${REF#refs/heads/}"
|
|
||||||
# On non-tag runs keep whatever is in tauri.conf.json
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
|
||||||
echo "Setting version to $VERSION"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
|
||||||
fi
|
|
||||||
grep '"version"' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: bun run tauri android init
|
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
|
||||||
run: ./scripts/sync-android-sources.sh
|
|
||||||
|
|
||||||
- name: Write signing keystore
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
|
|
||||||
cat > src-tauri/gen/android/keystore.properties <<EOF
|
|
||||||
storeFile=$RUNNER_TEMP/jellytau-release.jks
|
|
||||||
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build signed Android APK
|
|
||||||
run: bun run tauri android build --apk true --target aarch64
|
|
||||||
|
|
||||||
- name: Collect & verify signed APK
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/android
|
|
||||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
|
|
||||||
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
|
|
||||||
cp "$APK" dist/android/jellytau-release.apk
|
|
||||||
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
|
||||||
echo "🔏 Verifying signature with $APKSIGNER"
|
|
||||||
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
|
|
||||||
ls -lah dist/android/
|
|
||||||
|
|
||||||
- name: Upload Android build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: dist/android/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
name: Create Release
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: [build-linux, build-android]
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: tag_name
|
|
||||||
run: |
|
|
||||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
echo "RELEASE_NAME=JellyTau ${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: artifacts/linux/
|
|
||||||
|
|
||||||
- name: Download Android artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: artifacts/android/
|
|
||||||
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
echo "## JellyTau $VERSION Release" > release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### Downloads" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux" >> release_notes.md
|
|
||||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
|
||||||
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Android" >> release_notes.md
|
|
||||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
|
||||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### What's New" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### Installation" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux (AppImage)" >> release_notes.md
|
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
|
||||||
echo "chmod +x jellytau_*.AppImage" >> release_notes.md
|
|
||||||
echo "./jellytau_*.AppImage" >> release_notes.md
|
|
||||||
echo "\`\`\`" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux (DEB)" >> release_notes.md
|
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
|
||||||
echo "sudo dpkg -i jellytau_*.deb" >> release_notes.md
|
|
||||||
echo "jellytau" >> release_notes.md
|
|
||||||
echo "\`\`\`" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Android" >> release_notes.md
|
|
||||||
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
|
|
||||||
echo "- Play Store: Coming soon" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### Known Issues" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### Requirements" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "**Linux:**" >> release_notes.md
|
|
||||||
echo "- 64-bit Linux system" >> release_notes.md
|
|
||||||
echo "- GLIBC 2.29+" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "**Android:**" >> release_notes.md
|
|
||||||
echo "- Android 8.0 or higher" >> release_notes.md
|
|
||||||
echo "- 50MB free storage" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "---" >> release_notes.md
|
|
||||||
echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
|
|
||||||
|
|
||||||
- name: Publish Gitea release & upload assets
|
|
||||||
env:
|
|
||||||
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
API="${GITHUB_SERVER_URL}/api/v1"
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
|
||||||
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
|
||||||
--arg tag "$VERSION" \
|
|
||||||
--arg name "JellyTau $VERSION" \
|
|
||||||
--rawfile body release_notes.md \
|
|
||||||
--argjson pre "$PRE" \
|
|
||||||
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
|
||||||
|
|
||||||
echo "📦 Creating release $VERSION on $REPO"
|
|
||||||
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
|
||||||
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD")
|
|
||||||
if [ "$HTTP" = "201" ]; then
|
|
||||||
RELEASE_ID=$(jq -r '.id' resp.json)
|
|
||||||
elif [ "$HTTP" = "409" ]; then
|
|
||||||
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
|
||||||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
|
||||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
|
||||||
else
|
|
||||||
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
|
|
||||||
fi
|
|
||||||
echo "Release id=$RELEASE_ID"
|
|
||||||
|
|
||||||
for f in artifacts/android/* artifacts/linux/*; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "⬆️ Uploading $(basename "$f")"
|
|
||||||
curl -fsS -X POST \
|
|
||||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$f" >/dev/null
|
|
||||||
done
|
|
||||||
echo "✅ Release $VERSION published with assets"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
name: Traceability Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate-traces:
|
|
||||||
runs-on: linux/amd64
|
|
||||||
name: Check Requirement Traces
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Extract traces
|
|
||||||
run: |
|
|
||||||
echo "🔍 Extracting requirement traces..."
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
|
|
||||||
- name: Validate traces
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📊 Validating requirement traceability..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Parse JSON
|
|
||||||
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
|
||||||
UR=$(jq '.byType.UR | length' traces-report.json)
|
|
||||||
IR=$(jq '.byType.IR | length' traces-report.json)
|
|
||||||
DR=$(jq '.byType.DR | length' traces-report.json)
|
|
||||||
JA=$(jq '.byType.JA | length' traces-report.json)
|
|
||||||
|
|
||||||
# Print coverage report
|
|
||||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Coverage Summary:"
|
|
||||||
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
|
|
||||||
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
|
|
||||||
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
|
|
||||||
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
COVERED=$((UR + IR + DR + JA))
|
|
||||||
TOTAL_REQS=114
|
|
||||||
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
|
|
||||||
|
|
||||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check minimum threshold
|
|
||||||
MIN_THRESHOLD=50
|
|
||||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
|
||||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
|
|
||||||
|
|
||||||
- name: Check modified files
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "🔍 Checking modified files for traces..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Get changed files
|
|
||||||
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || echo "")
|
|
||||||
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
echo "✅ No TypeScript/Rust files changed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📝 Changed files:"
|
|
||||||
echo "$CHANGED" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check each file
|
|
||||||
# Pipe into the loop instead of a here-string (<<<) so this step works
|
|
||||||
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
|
|
||||||
# for the same reason. The loop runs in a subshell (so a counter var
|
|
||||||
# wouldn't survive), so we record warnings in a temp file and count it
|
|
||||||
# afterwards.
|
|
||||||
MISSING_FILE=$(mktemp)
|
|
||||||
echo "$CHANGED" | while IFS= read -r file; do
|
|
||||||
# Skip test files
|
|
||||||
case "$file" in
|
|
||||||
*.test.*) continue ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
if ! grep -q "TRACES:" "$file"; then
|
|
||||||
echo "⚠️ Missing TRACES: $file"
|
|
||||||
echo "$file" >> "$MISSING_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
|
|
||||||
rm -f "$MISSING_FILE"
|
|
||||||
|
|
||||||
if [ "$MISSING_TRACES" -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "📝 Recommendation: Add TRACES comments to new/modified code"
|
|
||||||
echo " Format: // TRACES: UR-001, UR-002 | DR-003"
|
|
||||||
echo ""
|
|
||||||
echo "💡 For more info, see: scripts/README.md"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate full report
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "📄 Generating full traceability report..."
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Display report summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "📊 Full Report Generated"
|
|
||||||
echo "📁 Location: docs/traceability.md"
|
|
||||||
echo ""
|
|
||||||
head -50 docs/traceability.md || true
|
|
||||||
|
|
||||||
- name: Save artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: traceability-reports
|
|
||||||
path: |
|
|
||||||
traces-report.json
|
|
||||||
docs/traceability.md
|
|
||||||
retention-days: 30
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
name: Requirement Traceability Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
traceability:
|
|
||||||
name: Validate Requirement Traces
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Extract requirement traces
|
|
||||||
run: bun run traces:json > traces.json
|
|
||||||
|
|
||||||
- name: Validate trace format
|
|
||||||
run: |
|
|
||||||
if ! jq empty traces.json 2>/dev/null; then
|
|
||||||
echo "❌ Invalid traces.json format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Traces JSON is valid"
|
|
||||||
|
|
||||||
- name: Check requirement coverage
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Extract coverage stats
|
|
||||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
|
||||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
|
||||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
|
||||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
|
||||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
|
||||||
|
|
||||||
echo "## 📊 Requirement Traceability Report"
|
|
||||||
echo ""
|
|
||||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
|
||||||
echo ""
|
|
||||||
echo "### Requirements Covered:"
|
|
||||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
|
||||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
|
||||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
|
||||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Set minimum coverage threshold (50%)
|
|
||||||
TOTAL_REQS=114
|
|
||||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
|
||||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
|
||||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
|
||||||
|
|
||||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
|
||||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check for new untraced code
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Find files modified in this PR/push
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
|
||||||
else
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
|
||||||
echo "✅ No source files changed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### Files Changed:"
|
|
||||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if changed files have TRACES
|
|
||||||
UNTRACED_FILES=""
|
|
||||||
while IFS= read -r file; do
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
# Skip test files and generated code
|
|
||||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if file has TRACES comments
|
|
||||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
|
||||||
UNTRACED_FILES+="$file"$'\n'
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$CHANGED_FILES"
|
|
||||||
|
|
||||||
if [ -n "$UNTRACED_FILES" ]; then
|
|
||||||
echo "⚠️ New files without TRACES:"
|
|
||||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
|
||||||
echo ""
|
|
||||||
echo "💡 Add TRACES comments to link code to requirements:"
|
|
||||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
|
||||||
else
|
|
||||||
echo "✅ All changed files have TRACES comments"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate traceability report
|
|
||||||
if: always()
|
|
||||||
run: bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Upload traceability report
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: traceability-report
|
|
||||||
path: docs/traceability.md
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Comment PR with coverage report
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
|
||||||
|
|
||||||
const urCount = traces.byType.UR.length;
|
|
||||||
const irCount = traces.byType.IR.length;
|
|
||||||
const drCount = traces.byType.DR.length;
|
|
||||||
const jaCount = traces.byType.JA.length;
|
|
||||||
const total = urCount + irCount + drCount + jaCount;
|
|
||||||
const coverage = Math.round((total / 114) * 100);
|
|
||||||
|
|
||||||
const comment = `## 📊 Requirement Traceability Report
|
|
||||||
|
|
||||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
|
||||||
|
|
||||||
### By Type:
|
|
||||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
|
||||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
|
||||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
|
||||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
|
||||||
|
|
||||||
**Total Traces:** ${traces.totalTraces}
|
|
||||||
|
|
||||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: comment
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
pnpm-lock.yaml
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
/build
|
|
||||||
/dist
|
|
||||||
/.svelte-kit
|
|
||||||
/package
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
coverage
|
|
||||||
.nyc_output
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# WebdriverIO E2E tests
|
|
||||||
e2e/logs/
|
|
||||||
e2e/screenshots/
|
|
||||||
wdio-*.log
|
|
||||||
|
|
||||||
# Vitest
|
|
||||||
.vitest
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android signing keystore (NEVER commit)
|
|
||||||
android-keystore/
|
|
||||||
|
|
||||||
# Local machine-specific Android NDK toolchain paths (do not commit)
|
|
||||||
src-tauri/.cargo/config.toml
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
|
||||||
FROM ubuntu:24.04 AS builder
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=34 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
# Build essentials
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
# JDK for Android
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
# Android build tools
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
# Additional development tools
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
|
||||||
libglib2.0-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libjavascriptcoregtk-4.1-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Setup Android SDK components
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
# Create working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy project files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install Node.js dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Install Rust dependencies
|
|
||||||
RUN cd src-tauri && cargo fetch && cd ..
|
|
||||||
|
|
||||||
# Build stage - Tests
|
|
||||||
FROM builder AS test
|
|
||||||
WORKDIR /app
|
|
||||||
RUN echo "Running tests..." && \
|
|
||||||
bunx svelte-kit sync && \
|
|
||||||
bun run test && \
|
|
||||||
cd src-tauri && cargo test && cd .. && \
|
|
||||||
echo "All tests passed!"
|
|
||||||
|
|
||||||
# Build stage - APK
|
|
||||||
FROM builder AS android-build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN cd src-tauri && cargo fetch && cd .. && \
|
|
||||||
echo "Building Android APK..." && \
|
|
||||||
bun run build && \
|
|
||||||
bun run tauri android build --apk true && \
|
|
||||||
echo "APK build complete!"
|
|
||||||
|
|
||||||
# Final output stage
|
|
||||||
FROM ubuntu:24.04 AS final
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=android-build /app/src-tauri/gen/android/app/build/outputs/apk /app/apk
|
|
||||||
|
|
||||||
VOLUME ["/app/apk"]
|
|
||||||
CMD ["/bin/bash", "-c", "echo 'APK files are available in /app/apk' && ls -lh /app/apk/"]
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# JellyTau Builder Image
|
|
||||||
# Pre-built image with all dependencies for building and testing
|
|
||||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=36 \
|
|
||||||
BUILD_TOOLS_VERSION=35.0.0 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
jq \
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
|
||||||
libglib2.0-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libjavascriptcoregtk-4.1-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android && \
|
|
||||||
rustup component add rustfmt clippy
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
|
|
||||||
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
|
|
||||||
|
|
||||||
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/bash"]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<h1 align="center">
|
|
||||||
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
|
||||||
JellyTau
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
|
||||||
|
|
||||||
Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
|
|
||||||
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
|
|
||||||
Android (ExoPlayer).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
This project uses [bun](https://bun.sh) as its package manager.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activate the Rust environment (fish shell)
|
|
||||||
source "$HOME/.cargo/env.fish"
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Run in development
|
|
||||||
bun run tauri dev
|
|
||||||
|
|
||||||
# Type-check the frontend
|
|
||||||
bun run check
|
|
||||||
|
|
||||||
# Build for Linux
|
|
||||||
bun run tauri build
|
|
||||||
|
|
||||||
# Build for Android
|
|
||||||
bun run tauri android build
|
|
||||||
```
|
|
||||||
|
|
||||||
For the full set of build, test, and Android helper scripts, see
|
|
||||||
[scripts/README.md](scripts/README.md).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Topic | Location |
|
|
||||||
|-------|----------|
|
|
||||||
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
|
||||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
|
||||||
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
|
||||||
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
|
||||||
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
|
||||||
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
|
||||||
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) +
|
|
||||||
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
|
|
||||||
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
|
|
||||||
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# Test service - runs tests only
|
|
||||||
test:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: test
|
|
||||||
container_name: jellytau-test
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
command: bash -c "bun run test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
|
|
||||||
|
|
||||||
# Android build service - builds APK after tests pass
|
|
||||||
android-build:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: android-build
|
|
||||||
container_name: jellytau-android-build
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- android-cache:/root/.cargo
|
|
||||||
- android-bun-cache:/root/.bun
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
depends_on:
|
|
||||||
- test
|
|
||||||
ports:
|
|
||||||
- "5172:5172" # In case you want to run dev server
|
|
||||||
|
|
||||||
# Development container - for interactive development
|
|
||||||
dev:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: builder
|
|
||||||
container_name: jellytau-dev
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- cargo-cache:/root/.cargo
|
|
||||||
- bun-cache:/root/.bun
|
|
||||||
- node-modules:/app/node_modules
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
- NDK_HOME=/opt/android-sdk/ndk/27.0.11902837
|
|
||||||
working_dir: /app
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
command: /bin/bash
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cargo-cache:
|
|
||||||
bun-cache:
|
|
||||||
android-cache:
|
|
||||||
android-bun-cache:
|
|
||||||
node-modules:
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
# Rust Backend Architecture
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/`
|
|
||||||
|
|
||||||
## Media Session State Machine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/session.rs`
|
|
||||||
|
|
||||||
The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.
|
|
||||||
|
|
||||||
**Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
|
|
||||||
Idle --> AudioActive : play_queue(audio)
|
|
||||||
Idle --> MovieActive : play_item(movie)
|
|
||||||
Idle --> TvShowActive : play_item(episode)
|
|
||||||
|
|
||||||
state "Audio Session" as AudioSession {
|
|
||||||
[*] --> AudioActive
|
|
||||||
AudioActive --> AudioInactive : playback_ended
|
|
||||||
AudioInactive --> AudioActive : resume/play
|
|
||||||
AudioActive --> AudioActive : next/previous
|
|
||||||
}
|
|
||||||
|
|
||||||
state "Movie Session" as MovieSession {
|
|
||||||
[*] --> MovieActive
|
|
||||||
MovieActive --> MovieInactive : playback_ended
|
|
||||||
MovieInactive --> MovieActive : resume
|
|
||||||
}
|
|
||||||
|
|
||||||
state "TV Show Session" as TvShowSession {
|
|
||||||
[*] --> TvShowActive
|
|
||||||
TvShowActive --> TvShowInactive : playback_ended
|
|
||||||
TvShowInactive --> TvShowActive : next_episode/resume
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioSession --> Idle : dismiss/clear_queue
|
|
||||||
AudioSession --> MovieSession : play_item(movie)
|
|
||||||
AudioSession --> TvShowSession : play_item(episode)
|
|
||||||
|
|
||||||
MovieSession --> Idle : dismiss/playback_complete
|
|
||||||
MovieSession --> AudioSession : play_queue(audio)
|
|
||||||
|
|
||||||
TvShowSession --> Idle : dismiss/series_complete
|
|
||||||
TvShowSession --> AudioSession : play_queue(audio)
|
|
||||||
|
|
||||||
note right of Idle
|
|
||||||
No active media session
|
|
||||||
Queue may exist but not playing
|
|
||||||
No miniplayer/video player shown
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of AudioSession
|
|
||||||
SHOW: Miniplayer (always visible)
|
|
||||||
- Active: Play/pause/skip controls enabled
|
|
||||||
- Inactive: Play button to resume queue
|
|
||||||
Persists until explicit dismiss
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of MovieSession
|
|
||||||
SHOW: Full video player
|
|
||||||
- Active: Video playing/paused
|
|
||||||
- Inactive: Resume dialog
|
|
||||||
Auto-dismiss when playback ends
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of TvShowSession
|
|
||||||
SHOW: Full video player + Next Episode UI
|
|
||||||
- Active: Video playing/paused
|
|
||||||
- Inactive: Next episode prompt
|
|
||||||
Auto-dismiss when series ends
|
|
||||||
end note
|
|
||||||
```
|
|
||||||
|
|
||||||
**Session State Enum:**
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum MediaSessionType {
|
|
||||||
/// No active session - browsing library
|
|
||||||
Idle,
|
|
||||||
|
|
||||||
/// Audio playback session (music, audiobooks, podcasts)
|
|
||||||
/// Persists until explicitly dismissed
|
|
||||||
Audio {
|
|
||||||
/// Last/current track being played
|
|
||||||
last_item: Option<MediaItem>,
|
|
||||||
/// True = playing/paused, False = stopped/ended
|
|
||||||
is_active: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Movie playback (single video, auto-dismiss on end)
|
|
||||||
Movie {
|
|
||||||
item: MediaItem,
|
|
||||||
is_active: bool, // true = playing/paused, false = ended
|
|
||||||
},
|
|
||||||
|
|
||||||
/// TV show playback (supports next episode auto-advance)
|
|
||||||
TvShow {
|
|
||||||
item: MediaItem,
|
|
||||||
series_id: String,
|
|
||||||
is_active: bool, // true = playing/paused, false = ended
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Transitions & Rules:**
|
|
||||||
|
|
||||||
| From State | Event | To State | UI Behavior | Notes |
|
|
||||||
|------------|-------|----------|-------------|-------|
|
|
||||||
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
|
|
||||||
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
|
|
||||||
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
|
|
||||||
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
|
|
||||||
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
|
|
||||||
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
|
|
||||||
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
|
|
||||||
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
|
|
||||||
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
|
|
||||||
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
|
|
||||||
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
|
|
||||||
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
|
|
||||||
|
|
||||||
**Key Design Decisions:**
|
|
||||||
|
|
||||||
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
|
|
||||||
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
|
|
||||||
3. **Single Active Session**: Playing new content type replaces current session
|
|
||||||
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
|
|
||||||
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
|
|
||||||
|
|
||||||
**Edge Cases Handled:**
|
|
||||||
|
|
||||||
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
|
|
||||||
- User wants to dismiss: Close button clears session -> Idle
|
|
||||||
- Switch content types: New session replaces old (audio -> movie)
|
|
||||||
- Paused for extended time: Session persists indefinitely
|
|
||||||
- Playback errors: Session stays inactive, allows retry
|
|
||||||
- Queue operations while idle: Queue exists but no session created until play
|
|
||||||
|
|
||||||
## Player State Machine (Low-Level Playback)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/state.rs`
|
|
||||||
|
|
||||||
The player uses a deterministic state machine with 6 states (operates within a media session):
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
Idle --> Loading : Load
|
|
||||||
Loading --> Playing : MediaLoaded
|
|
||||||
Playing --> Paused : Pause
|
|
||||||
Paused --> Playing : Play
|
|
||||||
Paused --> Seeking : Seek
|
|
||||||
Seeking --> Playing : PositionUpdate
|
|
||||||
Playing --> Idle : Stop
|
|
||||||
Paused --> Idle : Stop
|
|
||||||
Idle --> Error : Error
|
|
||||||
Loading --> Error : Error
|
|
||||||
Playing --> Error : Error
|
|
||||||
Paused --> Error : Error
|
|
||||||
Seeking --> Error : Error
|
|
||||||
|
|
||||||
state Playing {
|
|
||||||
[*] : position, duration
|
|
||||||
}
|
|
||||||
state Paused {
|
|
||||||
[*] : position, duration
|
|
||||||
}
|
|
||||||
state Seeking {
|
|
||||||
[*] : target
|
|
||||||
}
|
|
||||||
state Error {
|
|
||||||
[*] : error message
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerState {
|
|
||||||
Idle,
|
|
||||||
Loading { media: MediaItem },
|
|
||||||
Playing { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Paused { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Seeking { media: MediaItem, target: f64 },
|
|
||||||
Error { media: Option<MediaItem>, error: String },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerEvent {
|
|
||||||
Load(MediaItem),
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Stop,
|
|
||||||
Seek(f64),
|
|
||||||
Next,
|
|
||||||
Previous,
|
|
||||||
MediaLoaded(f64), // duration
|
|
||||||
PositionUpdate(f64), // position
|
|
||||||
PlaybackEnded,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Playback Mode State Machine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
|
||||||
|
|
||||||
The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
|
|
||||||
Idle --> Local : play_queue()
|
|
||||||
Idle --> Remote : transfer_to_remote(session_id)
|
|
||||||
|
|
||||||
Local --> Remote : transfer_to_remote(session_id)
|
|
||||||
Local --> Idle : stop()
|
|
||||||
|
|
||||||
Remote --> Local : transfer_to_local()
|
|
||||||
Remote --> Idle : session_disconnected()
|
|
||||||
Remote --> Idle : stop()
|
|
||||||
|
|
||||||
state Local {
|
|
||||||
[*] : Playing on device
|
|
||||||
[*] : ExoPlayer active
|
|
||||||
[*] : Volume buttons -> device
|
|
||||||
}
|
|
||||||
|
|
||||||
state Remote {
|
|
||||||
[*] : Controlling session
|
|
||||||
[*] : session_id
|
|
||||||
[*] : Volume buttons -> remote
|
|
||||||
[*] : Android: VolumeProvider active
|
|
||||||
}
|
|
||||||
|
|
||||||
state Idle {
|
|
||||||
[*] : No active playback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlaybackMode {
|
|
||||||
Local, // Playing on local device
|
|
||||||
Remote { session_id: String }, // Controlling remote Jellyfin session
|
|
||||||
Idle, // No active playback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Transitions:**
|
|
||||||
|
|
||||||
| From | Event | To | Side Effects |
|
|
||||||
|------|-------|-----|----|
|
|
||||||
| Idle | `play_queue()` | Local | Start local playback |
|
|
||||||
| Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session |
|
|
||||||
| Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) |
|
|
||||||
| Local | `stop()` | Idle | Stop local playback |
|
|
||||||
| Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume |
|
|
||||||
| Remote | `stop()` | Idle | Stop remote playback, disable remote volume |
|
|
||||||
| Remote | `session_disconnected()` | Idle | Session lost, disable remote volume |
|
|
||||||
|
|
||||||
**Integration with Player State Machine:**
|
|
||||||
|
|
||||||
- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
|
|
||||||
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
|
|
||||||
- When `PlaybackMode = Idle`: Player state is Idle
|
|
||||||
|
|
||||||
**Android Volume Control Integration:**
|
|
||||||
|
|
||||||
When transitioning to `Remote` mode on Android:
|
|
||||||
1. Call `enable_remote_volume(initial_volume)`
|
|
||||||
2. VolumeProviderCompat intercepts hardware volume buttons
|
|
||||||
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
|
|
||||||
4. Volume commands routed to remote session via Jellyfin API
|
|
||||||
|
|
||||||
When transitioning away from `Remote` mode:
|
|
||||||
1. Call `disable_remote_volume()`
|
|
||||||
2. Volume buttons return to controlling device volume
|
|
||||||
3. PlaybackStateCompat set to STATE_NONE
|
|
||||||
4. VolumeProviderCompat is cleared
|
|
||||||
|
|
||||||
## Media Item & Source
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/media.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct MediaItem {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub artist: Option<String>,
|
|
||||||
pub album: Option<String>,
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
pub artwork_url: Option<String>,
|
|
||||||
pub media_type: MediaType,
|
|
||||||
pub source: MediaSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum MediaType {
|
|
||||||
Audio,
|
|
||||||
Video,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum MediaSource {
|
|
||||||
Remote {
|
|
||||||
stream_url: String,
|
|
||||||
jellyfin_item_id: String,
|
|
||||||
},
|
|
||||||
Local {
|
|
||||||
file_path: PathBuf,
|
|
||||||
jellyfin_item_id: Option<String>,
|
|
||||||
},
|
|
||||||
DirectUrl {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `MediaSource` enum enables:
|
|
||||||
- **Remote**: Streaming from Jellyfin server
|
|
||||||
- **Local**: Downloaded/cached files (future offline support)
|
|
||||||
- **DirectUrl**: Direct URLs (channel plugins, external sources)
|
|
||||||
|
|
||||||
## Queue Manager
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/queue.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct QueueManager {
|
|
||||||
items: Vec<MediaItem>,
|
|
||||||
current_index: Option<usize>,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat: RepeatMode,
|
|
||||||
shuffle_order: Vec<usize>, // Fisher-Yates permutation
|
|
||||||
history: Vec<usize>, // For back navigation in shuffle
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum RepeatMode {
|
|
||||||
Off,
|
|
||||||
All,
|
|
||||||
One,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Queue Navigation Logic:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
QM[QueueManager]
|
|
||||||
QM --> Shuffle
|
|
||||||
QM --> Repeat
|
|
||||||
QM --> History
|
|
||||||
|
|
||||||
subgraph Shuffle["Shuffle Mode"]
|
|
||||||
ShuffleOff["OFF<br/>next() returns index + 1"]
|
|
||||||
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Repeat["Repeat Mode"]
|
|
||||||
RepeatOff["OFF<br/>next() at end: -> None"]
|
|
||||||
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
|
|
||||||
RepeatOne["ONE<br/>next() returns same item"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph History["History"]
|
|
||||||
HistoryDesc["Used for previous()<br/>in shuffle mode"]
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Favorites System
|
|
||||||
|
|
||||||
**Location**:
|
|
||||||
- Service: `src/lib/services/favorites.ts`
|
|
||||||
- Component: `src/lib/components/FavoriteButton.svelte`
|
|
||||||
- Backend: `src-tauri/src/commands/storage.rs`
|
|
||||||
|
|
||||||
The favorites system implements optimistic updates with server synchronization:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
|
|
||||||
Service -->|1. Optimistic| LocalDB[(SQLite user_data)]
|
|
||||||
Service -->|2. Sync| JellyfinAPI[Jellyfin API]
|
|
||||||
Service -->|3. Mark Synced| LocalDB
|
|
||||||
|
|
||||||
JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
|
||||||
JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
|
||||||
|
|
||||||
LocalDB -->|is_favorite<br/>pending_sync| UserData[user_data table]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow**:
|
|
||||||
1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
|
|
||||||
2. `toggleFavorite()` service function handles the logic:
|
|
||||||
- Updates local SQLite database immediately (optimistic update)
|
|
||||||
- Attempts to sync with Jellyfin server
|
|
||||||
- Marks as synced if successful, otherwise leaves `pending_sync = 1`
|
|
||||||
3. UI reflects the change immediately without waiting for server response
|
|
||||||
|
|
||||||
**Components**:
|
|
||||||
|
|
||||||
- **FavoriteButton.svelte**: Reusable heart button component
|
|
||||||
- Configurable size (sm/md/lg)
|
|
||||||
- Red when favorited, gray when not
|
|
||||||
- Loading state during toggle
|
|
||||||
- Bindable `isFavorite` prop for two-way binding
|
|
||||||
|
|
||||||
- **Integration Points**:
|
|
||||||
- MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
|
|
||||||
- Full AudioPlayer: Shows favorite button (planned)
|
|
||||||
- Album/Artist detail pages: Shows favorite button (planned)
|
|
||||||
|
|
||||||
**Database Schema**:
|
|
||||||
- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1)
|
|
||||||
- `user_data.pending_sync`: Indicates if local changes need syncing
|
|
||||||
|
|
||||||
**Tauri Commands**:
|
|
||||||
- `storage_toggle_favorite`: Updates favorite status in local database
|
|
||||||
- `storage_mark_synced`: Clears pending_sync flag after successful sync
|
|
||||||
|
|
||||||
**API Methods**:
|
|
||||||
- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin
|
|
||||||
- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin
|
|
||||||
|
|
||||||
## Player Backend Trait
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/backend.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait PlayerBackend: Send + Sync {
|
|
||||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
|
||||||
fn play(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn stop(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
|
||||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
|
||||||
fn position(&self) -> f64;
|
|
||||||
fn duration(&self) -> Option<f64>;
|
|
||||||
fn state(&self) -> PlayerState;
|
|
||||||
fn is_loaded(&self) -> bool;
|
|
||||||
fn volume(&self) -> f32;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementations:**
|
|
||||||
- `NullBackend` - Mock backend for testing
|
|
||||||
- `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md))
|
|
||||||
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md))
|
|
||||||
|
|
||||||
## Player Controller
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mod.rs`
|
|
||||||
|
|
||||||
The `PlayerController` orchestrates playback:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct PlayerController {
|
|
||||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
||||||
queue: Arc<Mutex<QueueManager>>,
|
|
||||||
muted: bool,
|
|
||||||
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
|
||||||
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
|
||||||
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
|
||||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
||||||
// ... other fields
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `play_item(item)`: Load and play single item (resets autoplay counter)
|
|
||||||
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
|
|
||||||
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
|
|
||||||
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
|
||||||
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
|
|
||||||
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
|
|
||||||
|
|
||||||
## Playlist System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`
|
|
||||||
|
|
||||||
**TRACES**: UR-014 | JA-019 | JA-020
|
|
||||||
|
|
||||||
The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.
|
|
||||||
|
|
||||||
**Types:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// A media item within a playlist, with its distinct playlist entry ID
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlaylistEntry {
|
|
||||||
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
|
|
||||||
pub playlist_item_id: String,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub item: MediaItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of creating a new playlist
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlaylistCreatedResult {
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.
|
|
||||||
|
|
||||||
**MediaRepository Trait Methods:**
|
|
||||||
```rust
|
|
||||||
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
|
|
||||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
|
||||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
|
||||||
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
|
||||||
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
|
|
||||||
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
|
|
||||||
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cache Strategy:**
|
|
||||||
- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository
|
|
||||||
- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
|
|
||||||
- Background cache update after server fetch via `save_playlist_items_to_cache()`
|
|
||||||
|
|
||||||
**Playlist Tauri Commands:**
|
|
||||||
|
|
||||||
| Command | Parameters | Returns |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` |
|
|
||||||
| `playlist_delete` | `handle, playlist_id` | `()` |
|
|
||||||
| `playlist_rename` | `handle, playlist_id, name` | `()` |
|
|
||||||
| `playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>` |
|
|
||||||
| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` |
|
|
||||||
| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` |
|
|
||||||
| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` |
|
|
||||||
|
|
||||||
## Tauri Commands (Player)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/player.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Returns |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| `player_play_item` | `PlayItemRequest` | `PlayerStatus` |
|
|
||||||
| `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` |
|
|
||||||
| `player_play` | - | `PlayerStatus` |
|
|
||||||
| `player_pause` | - | `PlayerStatus` |
|
|
||||||
| `player_toggle` | - | `PlayerStatus` |
|
|
||||||
| `player_stop` | - | `PlayerStatus` |
|
|
||||||
| `player_next` | - | `PlayerStatus` |
|
|
||||||
| `player_previous` | - | `PlayerStatus` |
|
|
||||||
| `player_seek` | `position: f64` | `PlayerStatus` |
|
|
||||||
| `player_set_volume` | `volume: f32` | `PlayerStatus` |
|
|
||||||
| `player_toggle_shuffle` | - | `QueueStatus` |
|
|
||||||
| `player_cycle_repeat` | - | `QueueStatus` |
|
|
||||||
| `player_get_status` | - | `PlayerStatus` |
|
|
||||||
| `player_get_queue` | - | `QueueStatus` |
|
|
||||||
| `player_get_session` | - | `MediaSessionType` |
|
|
||||||
| `player_dismiss_session` | - | `()` |
|
|
||||||
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
|
|
||||||
| `player_cancel_sleep_timer` | - | `()` |
|
|
||||||
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
|
|
||||||
| `player_get_video_settings` | - | `VideoSettings` |
|
|
||||||
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
|
|
||||||
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
|
|
||||||
| `player_on_playback_ended` | - | `()` |
|
|
||||||
@@ -1,647 +0,0 @@
|
|||||||
# Svelte Frontend Architecture
|
|
||||||
|
|
||||||
## Store Structure
|
|
||||||
|
|
||||||
**Location**: `src/lib/stores/`
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Stores
|
|
||||||
subgraph auth["auth.ts"]
|
|
||||||
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
|
|
||||||
end
|
|
||||||
subgraph playerStore["player.ts"]
|
|
||||||
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
|
|
||||||
end
|
|
||||||
subgraph queueStore["queue.ts"]
|
|
||||||
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
|
|
||||||
end
|
|
||||||
subgraph libraryStore["library.ts"]
|
|
||||||
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
|
|
||||||
end
|
|
||||||
subgraph Derived["Derived Stores"]
|
|
||||||
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Music Library Architecture
|
|
||||||
|
|
||||||
**Category-Based Navigation:**
|
|
||||||
|
|
||||||
JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.
|
|
||||||
|
|
||||||
**Route Structure:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
Music["/library/music<br/>(Landing page with category cards)"]
|
|
||||||
Tracks["Tracks<br/>(List view only)"]
|
|
||||||
Artists["Artists<br/>(Grid view)"]
|
|
||||||
Albums["Albums<br/>(Grid view)"]
|
|
||||||
Playlists["Playlists<br/>(Grid view)"]
|
|
||||||
Genres["Genres<br/>(Genre browser)"]
|
|
||||||
|
|
||||||
Music --> Tracks
|
|
||||||
Music --> Artists
|
|
||||||
Music --> Albums
|
|
||||||
Music --> Playlists
|
|
||||||
Music --> Genres
|
|
||||||
```
|
|
||||||
|
|
||||||
**View Enforcement:**
|
|
||||||
|
|
||||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
|
||||||
|--------------|-----------|----------------|----------------|
|
|
||||||
| Tracks | List (forced) | No | `TrackList` |
|
|
||||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
|
||||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
|
||||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
|
||||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
|
||||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
|
||||||
|
|
||||||
**TrackList Component:**
|
|
||||||
|
|
||||||
The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:
|
|
||||||
|
|
||||||
- **No Thumbnails**: Track numbers only (transform to play button on hover)
|
|
||||||
- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration
|
|
||||||
- **Mobile Layout**: Compact rows with track number and metadata
|
|
||||||
- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility
|
|
||||||
- **Click Behavior**: Clicking a track plays it and queues all filtered tracks
|
|
||||||
|
|
||||||
**Example Usage:**
|
|
||||||
```svelte
|
|
||||||
<TrackList
|
|
||||||
tracks={filteredTracks}
|
|
||||||
loading={loading}
|
|
||||||
showArtist={true}
|
|
||||||
showAlbum={true}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**LibraryGrid forceGrid Prop:**
|
|
||||||
|
|
||||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
|
||||||
|
|
||||||
## Playback Reporting Service
|
|
||||||
|
|
||||||
**Location**: `src/lib/services/playbackReporting.ts`
|
|
||||||
|
|
||||||
The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:
|
|
||||||
- Offline "Continue Watching" functionality
|
|
||||||
- Sync queue for when network is unavailable
|
|
||||||
- Consistent progress across app restarts
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant VideoPlayer
|
|
||||||
participant PlaybackService as playbackReporting.ts
|
|
||||||
participant LocalDB as Local SQLite<br/>(Tauri Commands)
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
|
|
||||||
VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
|
|
||||||
|
|
||||||
par Local Storage (always works)
|
|
||||||
PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
|
|
||||||
LocalDB-->>PlaybackService: Ok (pending_sync = true)
|
|
||||||
and Server Sync (if online)
|
|
||||||
PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
|
|
||||||
Jellyfin-->>PlaybackService: Ok
|
|
||||||
PlaybackService->>LocalDB: invoke("storage_mark_synced")
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Service Functions:**
|
|
||||||
- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
|
|
||||||
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
|
|
||||||
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends
|
|
||||||
|
|
||||||
**Tauri Commands:**
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) |
|
|
||||||
| `storage_mark_played` | Mark item as played, increment play count |
|
|
||||||
| `storage_get_playback_progress` | Get stored progress for an item |
|
|
||||||
| `storage_mark_synced` | Clear `pending_sync` flag after successful server sync |
|
|
||||||
|
|
||||||
**Database Schema Notes:**
|
|
||||||
- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
|
|
||||||
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
|
|
||||||
|
|
||||||
**Resume Playback Feature:**
|
|
||||||
- When loading media for playback, the app checks local database for saved progress
|
|
||||||
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
|
|
||||||
- User can choose to "Resume" from saved position or "Start from Beginning"
|
|
||||||
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
|
|
||||||
- For audio: Seeks to resume position after loading via MPV backend
|
|
||||||
- Implemented in `src/routes/player/[id]/+page.svelte`
|
|
||||||
|
|
||||||
## Repository Architecture (Rust-Based)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/`
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
classDiagram
|
|
||||||
class MediaRepository {
|
|
||||||
<<trait>>
|
|
||||||
+get_libraries()
|
|
||||||
+get_items(parent_id, options)
|
|
||||||
+get_item(item_id)
|
|
||||||
+search(query, options)
|
|
||||||
+get_latest_items(parent_id, limit)
|
|
||||||
+get_resume_items(parent_id, limit)
|
|
||||||
+get_next_up_episodes(series_id, limit)
|
|
||||||
+get_genres(parent_id)
|
|
||||||
+get_playback_info(item_id)
|
|
||||||
+report_playback_start(item_id, position_ticks)
|
|
||||||
+report_playback_progress(item_id, position_ticks, is_paused)
|
|
||||||
+report_playback_stopped(item_id, position_ticks)
|
|
||||||
+mark_favorite(item_id)
|
|
||||||
+unmark_favorite(item_id)
|
|
||||||
+get_person(person_id)
|
|
||||||
+get_items_by_person(person_id, options)
|
|
||||||
+get_image_url(item_id, image_type, options)
|
|
||||||
+create_playlist(name, item_ids)
|
|
||||||
+delete_playlist(playlist_id)
|
|
||||||
+rename_playlist(playlist_id, name)
|
|
||||||
+get_playlist_items(playlist_id)
|
|
||||||
+add_to_playlist(playlist_id, item_ids)
|
|
||||||
+remove_from_playlist(playlist_id, entry_ids)
|
|
||||||
+move_playlist_item(playlist_id, item_id, new_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
class OnlineRepository {
|
|
||||||
-http_client: Arc~HttpClient~
|
|
||||||
-server_url: String
|
|
||||||
-user_id: String
|
|
||||||
-access_token: String
|
|
||||||
-connectivity: Option~Arc~ConnectivityMonitor~~
|
|
||||||
+new()
|
|
||||||
+with_connectivity()
|
|
||||||
-report_outcome()
|
|
||||||
}
|
|
||||||
|
|
||||||
class OfflineRepository {
|
|
||||||
-db_service: Arc~DatabaseService~
|
|
||||||
-server_id: String
|
|
||||||
-user_id: String
|
|
||||||
+new()
|
|
||||||
+cache_library()
|
|
||||||
+cache_items()
|
|
||||||
+cache_item()
|
|
||||||
}
|
|
||||||
|
|
||||||
class HybridRepository {
|
|
||||||
-online: Arc~OnlineRepository~
|
|
||||||
-offline: Arc~OfflineRepository~
|
|
||||||
+new()
|
|
||||||
-parallel_race()
|
|
||||||
-cache_with_timeout()
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaRepository <|.. OnlineRepository
|
|
||||||
MediaRepository <|.. OfflineRepository
|
|
||||||
MediaRepository <|.. HybridRepository
|
|
||||||
|
|
||||||
HybridRepository --> OnlineRepository
|
|
||||||
HybridRepository --> OfflineRepository
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
|
|
||||||
1. **Cache-First Racing Strategy** (`hybrid.rs`):
|
|
||||||
- Runs cache (SQLite) and server (HTTP) queries in parallel
|
|
||||||
- Cache has 100ms timeout
|
|
||||||
- Returns cache result if it has meaningful content
|
|
||||||
- Falls back to server result otherwise
|
|
||||||
- Background cache updates planned
|
|
||||||
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
|
|
||||||
|
|
||||||
2. **Handle-Based Resource Management** (`repository.rs` commands):
|
|
||||||
```rust
|
|
||||||
// Frontend creates repository with UUID handle
|
|
||||||
repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
|
|
||||||
|
|
||||||
// All operations use handle for identification
|
|
||||||
repository_get_libraries(handle: String) -> Vec<Library>
|
|
||||||
repository_get_items(handle: String, ...) -> SearchResult
|
|
||||||
|
|
||||||
// Cleanup when done
|
|
||||||
repository_destroy(handle: String)
|
|
||||||
```
|
|
||||||
- Enables multiple concurrent repository instances
|
|
||||||
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
|
|
||||||
- No global state conflicts
|
|
||||||
|
|
||||||
3. **Frontend API Layer** (`src/lib/api/repository-client.ts`):
|
|
||||||
- Thin TypeScript wrapper over Rust commands
|
|
||||||
- Maintains handle throughout session
|
|
||||||
- All methods: `invoke<T>("repository_operation", { handle, ...args })`
|
|
||||||
- ~100 lines (down from 1061 lines)
|
|
||||||
|
|
||||||
## Playback Mode System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
|
||||||
|
|
||||||
The playback mode system manages transitions between local device playback and remote Jellyfin session control:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum PlaybackMode {
|
|
||||||
Local, // Playing on local device
|
|
||||||
Remote { session_id: String }, // Controlling remote session
|
|
||||||
Idle, // Not playing
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PlaybackModeManager {
|
|
||||||
current_mode: PlaybackMode,
|
|
||||||
player_controller: Arc<Mutex<PlayerController>>,
|
|
||||||
jellyfin_client: Arc<JellyfinClient>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Operations:**
|
|
||||||
|
|
||||||
1. **Transfer to Remote** (`transfer_to_remote(session_id)`):
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI
|
|
||||||
participant Manager as PlaybackModeManager
|
|
||||||
participant Player as PlayerController
|
|
||||||
participant Jellyfin as Jellyfin API
|
|
||||||
|
|
||||||
UI->>Manager: transfer_to_remote(session_id)
|
|
||||||
Manager->>Player: Extract queue items
|
|
||||||
Manager->>Manager: Get Jellyfin IDs from queue
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing
|
|
||||||
Note over Jellyfin: Start playback with queue
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
|
|
||||||
Note over Jellyfin: Seek to current position
|
|
||||||
Manager->>Player: Stop local playback
|
|
||||||
Manager->>Manager: Set mode to Remote
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`):
|
|
||||||
- Stops remote session playback
|
|
||||||
- Prepares local player to resume
|
|
||||||
- Sets mode to Local
|
|
||||||
|
|
||||||
**Tauri Commands** (`playback_mode.rs`):
|
|
||||||
- `playback_mode_get_current()` -> Returns current PlaybackMode
|
|
||||||
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
|
|
||||||
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
|
|
||||||
- `playback_mode_is_transferring()` -> Check transfer state
|
|
||||||
- `playback_mode_set(mode)` -> Direct mode setting
|
|
||||||
|
|
||||||
**Frontend Store** (`src/lib/stores/playbackMode.ts`):
|
|
||||||
- Thin wrapper calling Rust commands
|
|
||||||
- Maintains UI state (isTransferring, transferError)
|
|
||||||
- Listens to mode change events from Rust
|
|
||||||
|
|
||||||
## Database Service Abstraction
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/storage/db_service.rs`
|
|
||||||
|
|
||||||
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait DatabaseService: Send + Sync {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
|
||||||
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
|
||||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
|
||||||
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RusqliteService {
|
|
||||||
connection: Arc<Mutex<Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseService for RusqliteService {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
|
||||||
let conn = self.connection.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// Execute query on blocking thread pool
|
|
||||||
}).await?
|
|
||||||
}
|
|
||||||
// ... other methods use spawn_blocking
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Benefits:**
|
|
||||||
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
|
||||||
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
|
||||||
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
|
||||||
- **Testable**: Can mock DatabaseService for tests
|
|
||||||
|
|
||||||
**Usage Pattern:**
|
|
||||||
```rust
|
|
||||||
// Before (blocking - causes UI freeze)
|
|
||||||
let conn = database.connection();
|
|
||||||
let conn = conn.lock().unwrap(); // BLOCKS
|
|
||||||
conn.query_row(...) // BLOCKS
|
|
||||||
|
|
||||||
// After (async - no freezing)
|
|
||||||
let db_service = database.service();
|
|
||||||
let query = Query::with_params("SELECT ...", vec![...]);
|
|
||||||
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Hierarchy
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Routes["Routes (src/routes/)"]
|
|
||||||
LoginPage["Login Page"]
|
|
||||||
LibLayout["Library Layout"]
|
|
||||||
LibDetail["Album/Series Detail"]
|
|
||||||
MusicCategory["Music Category Landing"]
|
|
||||||
Tracks["Tracks"]
|
|
||||||
Artists["Artists"]
|
|
||||||
Albums["Albums"]
|
|
||||||
Playlists["Playlists"]
|
|
||||||
Genres["Genres"]
|
|
||||||
Downloads["Downloads Page"]
|
|
||||||
Settings["Settings Page"]
|
|
||||||
PlayerPage["Player Page"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PlayerComps["Player Components"]
|
|
||||||
AudioPlayer["AudioPlayer"]
|
|
||||||
VideoPlayer["VideoPlayer"]
|
|
||||||
MiniPlayer["MiniPlayer"]
|
|
||||||
Controls["Controls"]
|
|
||||||
Queue["Queue"]
|
|
||||||
SleepTimerModal["SleepTimerModal"]
|
|
||||||
SleepTimerIndicator["SleepTimerIndicator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph SessionComps["Sessions Components"]
|
|
||||||
CastButton["CastButton"]
|
|
||||||
SessionModal["SessionPickerModal"]
|
|
||||||
SessionCard["SessionCard"]
|
|
||||||
SessionsList["SessionsList"]
|
|
||||||
RemoteControls["RemoteControls"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph LibraryComps["Library Components"]
|
|
||||||
LibGrid["LibraryGrid"]
|
|
||||||
LibListView["LibraryListView"]
|
|
||||||
TrackList["TrackList"]
|
|
||||||
PlaylistDetail["PlaylistDetailView"]
|
|
||||||
DownloadBtn["DownloadButton"]
|
|
||||||
MediaCard["MediaCard"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PlaylistComps["Playlist Components"]
|
|
||||||
CreatePlaylistModal["CreatePlaylistModal"]
|
|
||||||
AddToPlaylistModal["AddToPlaylistModal"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph CommonComps["Common Components"]
|
|
||||||
ScrollPicker["ScrollPicker"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph OtherComps["Other Components"]
|
|
||||||
Search["Search"]
|
|
||||||
FavoriteBtn["FavoriteButton"]
|
|
||||||
DownloadItem["DownloadItem"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LibLayout --> PlayerComps
|
|
||||||
LibLayout --> LibDetail
|
|
||||||
MusicCategory --> Tracks
|
|
||||||
MusicCategory --> Artists
|
|
||||||
MusicCategory --> Albums
|
|
||||||
MusicCategory --> Playlists
|
|
||||||
MusicCategory --> Genres
|
|
||||||
LibDetail --> LibraryComps
|
|
||||||
Playlists --> PlaylistComps
|
|
||||||
Playlists --> PlaylistDetail
|
|
||||||
Downloads --> DownloadItem
|
|
||||||
PlayerPage --> PlayerComps
|
|
||||||
|
|
||||||
MiniPlayer --> CastButton
|
|
||||||
CastButton --> SessionModal
|
|
||||||
SleepTimerModal --> ScrollPicker
|
|
||||||
PlayerComps --> LibraryComps
|
|
||||||
```
|
|
||||||
|
|
||||||
## MiniPlayer Behavior
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/player/MiniPlayer.svelte`
|
|
||||||
|
|
||||||
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
|
|
||||||
|
|
||||||
**Touch Gesture Handling:**
|
|
||||||
|
|
||||||
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function handleTouchStart(e: TouchEvent) {
|
|
||||||
touchStartX = e.touches[0].clientX;
|
|
||||||
touchStartY = e.touches[0].clientY;
|
|
||||||
touchEndX = touchStartX; // Initialize to start position
|
|
||||||
touchEndY = touchStartY; // Prevents taps being treated as swipes
|
|
||||||
isSwiping = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
|
|
||||||
|
|
||||||
**Skip Button State:**
|
|
||||||
|
|
||||||
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
|
||||||
|
|
||||||
## Sleep Timer Architecture
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
|
|
||||||
|
|
||||||
**TRACES**: UR-026 | DR-029
|
|
||||||
|
|
||||||
The sleep timer supports three modes for stopping playback:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
||||||
pub enum SleepTimerMode {
|
|
||||||
Off,
|
|
||||||
Time { end_time: i64 }, // Unix timestamp in milliseconds
|
|
||||||
EndOfTrack, // Stop after current track/episode
|
|
||||||
Episodes { remaining: u32 }, // Stop after N more episodes
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Timer Modes:**
|
|
||||||
|
|
||||||
| Mode | Trigger | How It Stops |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
|
|
||||||
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
|
|
||||||
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
|
|
||||||
|
|
||||||
**Time-Based Timer Flow:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as SleepTimerModal
|
|
||||||
participant Store as sleepTimer store
|
|
||||||
participant Rust as PlayerController
|
|
||||||
participant Thread as Timer Thread
|
|
||||||
participant Backend as PlayerBackend
|
|
||||||
|
|
||||||
UI->>Store: setTimeTimer(30)
|
|
||||||
Store->>Rust: invoke("player_set_sleep_timer", {mode})
|
|
||||||
Rust->>Rust: Set SleepTimerMode::Time { end_time }
|
|
||||||
Rust->>UI: Emit SleepTimerChanged event
|
|
||||||
|
|
||||||
loop Every 1 second
|
|
||||||
Thread->>Thread: update_remaining_seconds()
|
|
||||||
Thread->>UI: Emit SleepTimerChanged (countdown)
|
|
||||||
alt remaining_seconds == 0
|
|
||||||
Thread->>Backend: stop()
|
|
||||||
Thread->>UI: Emit SleepTimerChanged (Off)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend Components:**
|
|
||||||
|
|
||||||
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
|
|
||||||
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
|
|
||||||
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
|
|
||||||
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
|
|
||||||
|
|
||||||
**Key Design Decisions:**
|
|
||||||
|
|
||||||
1. **All logic in Rust**: Frontend only displays state and invokes commands
|
|
||||||
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
|
|
||||||
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
|
|
||||||
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
|
|
||||||
|
|
||||||
## Auto-Play Episode Limit
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
|
|
||||||
|
|
||||||
**TRACES**: UR-023 | DR-049
|
|
||||||
|
|
||||||
Limits how many episodes auto-play consecutively before requiring manual intervention.
|
|
||||||
|
|
||||||
**Settings:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In AutoplaySettings (runtime, in PlayerController)
|
|
||||||
pub struct AutoplaySettings {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub countdown_seconds: u32,
|
|
||||||
pub max_episodes: u32, // 0 = unlimited
|
|
||||||
}
|
|
||||||
|
|
||||||
// In VideoSettings (persisted, settings page)
|
|
||||||
pub struct VideoSettings {
|
|
||||||
pub auto_play_next_episode: bool,
|
|
||||||
pub auto_play_countdown_seconds: u32,
|
|
||||||
pub auto_play_max_episodes: u32, // 0 = unlimited
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Session-Based Counter:**
|
|
||||||
|
|
||||||
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
|
|
||||||
|
|
||||||
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
|
|
||||||
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
|
|
||||||
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
|
|
||||||
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
|
|
||||||
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
|
|
||||||
FetchNext --> IncrementCount["increment_autoplay_count()"]
|
|
||||||
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
|
|
||||||
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
|
|
||||||
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
|
|
||||||
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
|
|
||||||
UserClick --> PlayItem["play_item() -> resets counter"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Settings Sync:**
|
|
||||||
|
|
||||||
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
|
|
||||||
|
|
||||||
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
|
|
||||||
|
|
||||||
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
|
|
||||||
|
|
||||||
## Player Page Navigation Guard
|
|
||||||
|
|
||||||
**Location**: `src/routes/player/[id]/+page.svelte`
|
|
||||||
|
|
||||||
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const alreadyPlayingMedia = get(storeCurrentMedia);
|
|
||||||
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
|
||||||
// Track already playing - show UI without restarting playback
|
|
||||||
// Fetch queue status for hasNext/hasPrevious
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
|
|
||||||
|
|
||||||
## Playlist Management UI
|
|
||||||
|
|
||||||
**TRACES**: UR-014 | JA-019 | JA-020
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`
|
|
||||||
|
|
||||||
The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
|
|
||||||
- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`):
|
|
||||||
- Modal for creating new playlists with a name input
|
|
||||||
- Accepts optional `initialItemIds` to pre-populate with tracks
|
|
||||||
- Keyboard support: Enter to create, Escape to close
|
|
||||||
- Navigates to new playlist detail page on creation
|
|
||||||
|
|
||||||
- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`):
|
|
||||||
- Modal listing all existing playlists to add tracks to
|
|
||||||
- "New Playlist" button for inline creation flow
|
|
||||||
- Shows playlist artwork via CachedImage
|
|
||||||
- Loading state with skeleton placeholders
|
|
||||||
|
|
||||||
- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`):
|
|
||||||
- Full playlist detail page with artwork, name, track count, total duration
|
|
||||||
- Click-to-rename with inline editing
|
|
||||||
- Play all / shuffle play buttons
|
|
||||||
- Delete with confirmation dialog
|
|
||||||
- Per-track removal buttons
|
|
||||||
- Uses `TrackList` component for track display
|
|
||||||
- Passes `{ type: "playlist", playlistId, playlistName }` context to player
|
|
||||||
|
|
||||||
- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`):
|
|
||||||
- Grid view using `GenericMediaListPage`
|
|
||||||
- Floating action button (FAB) to create new playlists
|
|
||||||
- Search by playlist name
|
|
||||||
|
|
||||||
**Frontend API Methods** (`src/lib/api/repository-client.ts`):
|
|
||||||
- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
|
|
||||||
- `deletePlaylist(playlistId)`
|
|
||||||
- `renamePlaylist(playlistId, name)`
|
|
||||||
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
|
|
||||||
- `addToPlaylist(playlistId, itemIds)`
|
|
||||||
- `removeFromPlaylist(playlistId, entryIds)`
|
|
||||||
- `movePlaylistItem(playlistId, itemId, newIndex)`
|
|
||||||
|
|
||||||
**Offline Sync** (`src/lib/services/syncService.ts`):
|
|
||||||
All playlist mutations are queued for offline sync:
|
|
||||||
- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
|
|
||||||
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# Data Flow
|
|
||||||
|
|
||||||
## Repository Query Flow (Cache-First)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as Svelte Component
|
|
||||||
participant Client as RepositoryClient (TS)
|
|
||||||
participant Rust as Tauri Command
|
|
||||||
participant Hybrid as HybridRepository
|
|
||||||
participant Cache as OfflineRepository (SQLite)
|
|
||||||
participant Server as OnlineRepository (HTTP)
|
|
||||||
participant Conn as ConnectivityMonitor
|
|
||||||
|
|
||||||
UI->>Client: getItems(parentId)
|
|
||||||
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
|
||||||
Rust->>Hybrid: get_items()
|
|
||||||
|
|
||||||
par Parallel Racing
|
|
||||||
Hybrid->>Cache: get_items() with 100ms timeout
|
|
||||||
Hybrid->>Server: get_items() (no timeout)
|
|
||||||
end
|
|
||||||
|
|
||||||
Note over Server,Conn: Every server request reports its outcome
|
|
||||||
alt Server succeeds (or answers with 4xx/5xx)
|
|
||||||
Server->>Conn: mark_reachable() (server is up)
|
|
||||||
else Network failure / timeout
|
|
||||||
Server->>Conn: mark_unreachable() (debounced)
|
|
||||||
end
|
|
||||||
|
|
||||||
alt Cache returns with content
|
|
||||||
Cache-->>Hybrid: Result with items
|
|
||||||
Hybrid-->>Rust: Return cache result
|
|
||||||
else Cache timeout or empty
|
|
||||||
Server-->>Hybrid: Fresh result
|
|
||||||
Hybrid-->>Rust: Return server result
|
|
||||||
end
|
|
||||||
|
|
||||||
Rust-->>Client: SearchResult
|
|
||||||
Client-->>UI: items[]
|
|
||||||
Note over UI: Reactive update
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Points:**
|
|
||||||
- Cache queries have 100ms timeout for responsiveness
|
|
||||||
- Server queries always run for fresh data
|
|
||||||
- Cache wins if it has meaningful content
|
|
||||||
- Automatic fallback to server if cache is empty/stale
|
|
||||||
- Background cache updates (planned)
|
|
||||||
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
|
||||||
|
|
||||||
## Playback Initiation Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant AudioPlayer
|
|
||||||
participant Tauri as Tauri IPC
|
|
||||||
participant Command as player_play_item()
|
|
||||||
participant Controller as PlayerController
|
|
||||||
participant Backend as PlayerBackend
|
|
||||||
participant Store as Frontend Store
|
|
||||||
|
|
||||||
User->>AudioPlayer: clicks play
|
|
||||||
AudioPlayer->>Tauri: invoke("player_play_item", {item})
|
|
||||||
Tauri->>Command: player_play_item()
|
|
||||||
Command->>Command: Convert PlayItemRequest -> MediaItem
|
|
||||||
Command->>Controller: play_item(item)
|
|
||||||
Controller->>Backend: load(item)
|
|
||||||
Note over Backend: State -> Loading
|
|
||||||
Controller->>Backend: play()
|
|
||||||
Note over Backend: State -> Playing
|
|
||||||
Controller-->>Command: Ok(())
|
|
||||||
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
|
|
||||||
Tauri-->>AudioPlayer: status
|
|
||||||
AudioPlayer->>Store: player.setPlaying(media, position, duration)
|
|
||||||
Note over Store: UI updates reactively
|
|
||||||
```
|
|
||||||
|
|
||||||
## Playback Mode Transfer Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as Cast Button
|
|
||||||
participant Store as playbackMode store
|
|
||||||
participant Rust as Tauri Command
|
|
||||||
participant Manager as PlaybackModeManager
|
|
||||||
participant Player as PlayerController
|
|
||||||
participant Jellyfin as Jellyfin API
|
|
||||||
|
|
||||||
UI->>Store: transferToRemote(sessionId)
|
|
||||||
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
|
|
||||||
Rust->>Manager: transfer_to_remote()
|
|
||||||
|
|
||||||
Manager->>Player: Get current queue
|
|
||||||
Player-->>Manager: Vec<MediaItem>
|
|
||||||
Manager->>Manager: Extract Jellyfin IDs
|
|
||||||
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
|
|
||||||
Jellyfin-->>Manager: 200 OK
|
|
||||||
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
|
|
||||||
Jellyfin-->>Manager: 200 OK
|
|
||||||
|
|
||||||
Manager->>Player: stop()
|
|
||||||
Manager->>Manager: mode = Remote {sessionId}
|
|
||||||
|
|
||||||
Manager-->>Rust: Ok(())
|
|
||||||
Rust-->>Store: PlaybackMode
|
|
||||||
Store->>UI: Update cast icon
|
|
||||||
```
|
|
||||||
|
|
||||||
## Queue Navigation Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
User["User clicks Next"] --> Invoke["invoke('player_next')"]
|
|
||||||
Invoke --> ControllerNext["controller.next()"]
|
|
||||||
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
|
|
||||||
|
|
||||||
QueueNext --> None["None<br/>(at end)"]
|
|
||||||
QueueNext --> Some["Some(next)"]
|
|
||||||
QueueNext --> Same["Same<br/>(repeat one)"]
|
|
||||||
|
|
||||||
Some --> PlayItem["play_item(next)<br/>Returns new status"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volume Control Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant Slider as Volume Slider
|
|
||||||
participant Handler as handleVolumeChange()
|
|
||||||
participant Tauri as Tauri IPC
|
|
||||||
participant Command as player_set_volume
|
|
||||||
participant Controller as PlayerController
|
|
||||||
participant Backend as MpvBackend/NullBackend
|
|
||||||
participant Events as playerEvents.ts
|
|
||||||
participant Store as Player Store
|
|
||||||
participant UI
|
|
||||||
|
|
||||||
User->>Slider: adjusts (0-100)
|
|
||||||
Slider->>Handler: oninput event
|
|
||||||
Handler->>Handler: Convert 0-100 -> 0.0-1.0
|
|
||||||
Handler->>Tauri: invoke("player_set_volume", {volume})
|
|
||||||
Tauri->>Command: player_set_volume
|
|
||||||
Command->>Controller: set_volume(volume)
|
|
||||||
Controller->>Backend: set_volume(volume)
|
|
||||||
Backend->>Backend: Clamp to 0.0-1.0
|
|
||||||
Note over Backend: MpvBackend: Send to MPV loop
|
|
||||||
Backend-->>Tauri: emit "player-event"
|
|
||||||
Tauri-->>Events: VolumeChanged event
|
|
||||||
Events->>Store: player.setVolume(volume)
|
|
||||||
Store-->>UI: Reactive update
|
|
||||||
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
- Volume is stored in the backend (NullBackend/MpvBackend)
|
|
||||||
- `PlayerController.volume()` delegates to backend
|
|
||||||
- `get_player_status()` returns `controller.volume()` (not hardcoded)
|
|
||||||
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Type Synchronization & Thread Safety
|
|
||||||
|
|
||||||
## PlayerState (Rust <-> TypeScript)
|
|
||||||
|
|
||||||
**Rust:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerState {
|
|
||||||
Idle,
|
|
||||||
Loading { media: MediaItem },
|
|
||||||
Playing { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Paused { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Seeking { media: MediaItem, target: f64 },
|
|
||||||
Error { media: Option<MediaItem>, error: String },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**TypeScript:**
|
|
||||||
```typescript
|
|
||||||
type PlayerState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "loading"; media: MediaItem }
|
|
||||||
| { kind: "playing"; media: MediaItem; position: number; duration: number }
|
|
||||||
| { kind: "paused"; media: MediaItem; position: number; duration: number }
|
|
||||||
| { kind: "seeking"; media: MediaItem; target: number }
|
|
||||||
| { kind: "error"; media: MediaItem | null; error: string };
|
|
||||||
```
|
|
||||||
|
|
||||||
## MediaItem Serialization
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Rust (serde serialization)
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct MediaItem {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub artist: Option<String>,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TypeScript
|
|
||||||
interface MediaItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tauri v2 IPC Parameter Naming Convention
|
|
||||||
|
|
||||||
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
|
|
||||||
|
|
||||||
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
|
|
||||||
await invoke("player_play_tracks", {
|
|
||||||
repositoryHandle: "handle-123", // Rust: repository_handle
|
|
||||||
request: { trackIds: ["id1"], startIndex: 0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
await invoke("remote_send_command", {
|
|
||||||
sessionId: "session-123", // Rust: session_id
|
|
||||||
command: "PlayPause"
|
|
||||||
});
|
|
||||||
|
|
||||||
await invoke("pin_item", {
|
|
||||||
itemId: "item-123" // Rust: item_id
|
|
||||||
});
|
|
||||||
|
|
||||||
// WRONG - snake_case causes "invalid args request" error on Android
|
|
||||||
await invoke("player_play_tracks", {
|
|
||||||
repository_handle: "handle-123", // Will fail!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameter Name Mapping (Rust -> Frontend)**:
|
|
||||||
|
|
||||||
| Rust Parameter | Frontend Parameter | Used By |
|
|
||||||
|----------------|-------------------|----|
|
|
||||||
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
|
|
||||||
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
|
|
||||||
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
|
|
||||||
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
|
|
||||||
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
|
|
||||||
| `item_ids` | `itemIds` | `remote_play_on_session` |
|
|
||||||
| `start_index` | `startIndex` | `remote_play_on_session` |
|
|
||||||
|
|
||||||
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
|
|
||||||
|
|
||||||
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
|
|
||||||
|
|
||||||
## Rust Backend Thread Safety
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Shared state wrapped in Arc<Mutex<>>
|
|
||||||
pub struct PlayerController {
|
|
||||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
||||||
queue: Arc<Mutex<QueueManager>>,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tauri state wrapper
|
|
||||||
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
|
|
||||||
|
|
||||||
// Command handler pattern
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
|
|
||||||
let mut controller = state.0.lock().unwrap(); // Acquire lock
|
|
||||||
controller.play()?; // Operate
|
|
||||||
Ok(get_player_status(&controller)) // Lock released
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Stores
|
|
||||||
|
|
||||||
Svelte stores are inherently reactive and thread-safe for UI updates:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { subscribe, update } = writable<PlayerStore>(initialState);
|
|
||||||
|
|
||||||
// Atomic updates
|
|
||||||
function setPlaying(media: MediaItem, position: number, duration: number) {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
state: { kind: "playing", media, position, duration }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
# Platform-Specific Player Backends
|
|
||||||
|
|
||||||
## Player Events System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/events.rs`
|
|
||||||
|
|
||||||
The player uses a push-based event system to notify the frontend of state changes:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum PlayerStatusEvent {
|
|
||||||
/// Playback position updated (emitted periodically during playback)
|
|
||||||
PositionUpdate { position: f64, duration: f64 },
|
|
||||||
|
|
||||||
/// Player state changed
|
|
||||||
StateChanged { state: String, media_id: Option<String> },
|
|
||||||
|
|
||||||
/// Media has finished loading and is ready to play
|
|
||||||
MediaLoaded { duration: f64 },
|
|
||||||
|
|
||||||
/// Playback has ended naturally
|
|
||||||
PlaybackEnded,
|
|
||||||
|
|
||||||
/// Buffering state changed
|
|
||||||
Buffering { percent: u8 },
|
|
||||||
|
|
||||||
/// An error occurred during playback
|
|
||||||
Error { message: String, recoverable: bool },
|
|
||||||
|
|
||||||
/// Volume changed
|
|
||||||
VolumeChanged { volume: f32, muted: bool },
|
|
||||||
|
|
||||||
/// Sleep timer state changed
|
|
||||||
SleepTimerChanged {
|
|
||||||
mode: SleepTimerMode,
|
|
||||||
remaining_seconds: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Show next episode popup with countdown
|
|
||||||
ShowNextEpisodePopup {
|
|
||||||
current_episode: MediaItem,
|
|
||||||
next_episode: MediaItem,
|
|
||||||
countdown_seconds: u32,
|
|
||||||
auto_advance: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Countdown tick (emitted every second during autoplay countdown)
|
|
||||||
CountdownTick { remaining_seconds: u32 },
|
|
||||||
|
|
||||||
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
|
||||||
QueueChanged {
|
|
||||||
items: Vec<MediaItem>,
|
|
||||||
current_index: Option<usize>,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat: RepeatMode,
|
|
||||||
has_next: bool,
|
|
||||||
has_previous: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
|
||||||
SessionChanged { session: MediaSessionType },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Events are emitted via Tauri's event system:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph Backend["Player Backend"]
|
|
||||||
MPV["MPV/ExoPlayer"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EventSystem["Event System"]
|
|
||||||
Emitter["TauriEventEmitter<br/>emit()"]
|
|
||||||
Bus["Tauri Event Bus<br/>'player-event'"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
Listener["playerEvents.ts<br/>Frontend Listener"]
|
|
||||||
Store["Player Store Update<br/>(position, state, etc)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
MPV --> Emitter --> Bus --> Listener --> Store
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
|
|
||||||
- Listens for `player-event` Tauri events
|
|
||||||
- Updates player/queue stores based on event type
|
|
||||||
- Auto-advances to next track on `PlaybackEnded`
|
|
||||||
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
|
|
||||||
|
|
||||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
|
||||||
|
|
||||||
## HTML5 Video Adapter (webview-rendered video)
|
|
||||||
|
|
||||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
|
||||||
`src-tauri/src/commands/player/timers.rs`
|
|
||||||
|
|
||||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
|
||||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
|
||||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
|
||||||
the real player, living outside Rust's reach.
|
|
||||||
|
|
||||||
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
|
||||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
|
||||||
authority:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph Webview["Webview"]
|
|
||||||
Video["HTML5 <video> / HLS.js"]
|
|
||||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
|
||||||
end
|
|
||||||
subgraph Backend["Rust"]
|
|
||||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
|
||||||
Controller["PlayerController"]
|
|
||||||
Emitter["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
Events["playerEvents.ts"]
|
|
||||||
Store["player store"]
|
|
||||||
end
|
|
||||||
|
|
||||||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
|
||||||
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
|
||||||
another event source feeding the existing pipeline.
|
|
||||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
|
||||||
60fps RAF loop.
|
|
||||||
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
|
||||||
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
|
||||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
|
||||||
("frontend only displays state and invokes commands") for the video path.
|
|
||||||
|
|
||||||
## MpvBackend (Linux)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
|
||||||
|
|
||||||
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph MainThread["Main Thread"]
|
|
||||||
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
|
|
||||||
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EventLoopThread["MPV Event Loop Thread"]
|
|
||||||
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
|
|
||||||
TauriEmitter["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
|
|
||||||
MpvBackend -->|"MpvCommand"| EventLoop
|
|
||||||
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
|
|
||||||
EventLoop -->|"Events"| TauriEmitter
|
|
||||||
TauriEmitter --> FrontendStore["Frontend Store"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Components:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Command enum sent to event loop thread
|
|
||||||
pub enum MpvCommand {
|
|
||||||
Load { url: String, media: MediaItem },
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Stop,
|
|
||||||
Seek(f64),
|
|
||||||
SetVolume(f32),
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared state between main thread and event loop
|
|
||||||
pub struct MpvSharedState {
|
|
||||||
pub state: PlayerState,
|
|
||||||
pub position: f64,
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
pub volume: f32,
|
|
||||||
pub is_loaded: bool,
|
|
||||||
pub current_media: Option<MediaItem>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Loop** (`event_loop.rs`):
|
|
||||||
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
|
|
||||||
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
|
|
||||||
- Emits position updates every 250ms during playback
|
|
||||||
- Processes commands from channel (non-blocking)
|
|
||||||
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
|
|
||||||
|
|
||||||
## ExoPlayerBackend (Android)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/android/` and Kotlin sources
|
|
||||||
|
|
||||||
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph RustNative["Rust (Native)"]
|
|
||||||
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
|
|
||||||
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
|
|
||||||
TauriEmitter2["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph KotlinJVM["Kotlin (JVM)"]
|
|
||||||
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
|
|
||||||
end
|
|
||||||
|
|
||||||
ExoBackend -->|"JNI Calls"| JellyTauPlayer
|
|
||||||
JellyTauPlayer -->|"Callbacks"| NativeFuncs
|
|
||||||
NativeFuncs --> TauriEmitter2
|
|
||||||
TauriEmitter2 --> FrontendStore2["Frontend Store"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kotlin Player** (`JellyTauPlayer.kt`):
|
|
||||||
```kotlin
|
|
||||||
class JellyTauPlayer(context: Context) {
|
|
||||||
private val exoPlayer: ExoPlayer
|
|
||||||
private var positionUpdateJob: Job?
|
|
||||||
|
|
||||||
// Methods callable from Rust via JNI
|
|
||||||
fun load(url: String, mediaId: String)
|
|
||||||
fun play()
|
|
||||||
fun pause()
|
|
||||||
fun stop()
|
|
||||||
fun seek(positionSeconds: Double)
|
|
||||||
fun setVolume(volume: Float)
|
|
||||||
|
|
||||||
// Native callbacks to Rust
|
|
||||||
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
|
||||||
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
|
||||||
private external fun nativeOnMediaLoaded(duration: Double)
|
|
||||||
private external fun nativeOnPlaybackEnded()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**JNI Callbacks** (Rust):
|
|
||||||
```rust
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
|
|
||||||
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
|
|
||||||
) {
|
|
||||||
// Update shared state
|
|
||||||
// Emit PlayerStatusEvent::PositionUpdate
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android MediaSession & Remote Volume Control
|
|
||||||
|
|
||||||
**Location**: `JellyTauPlaybackService.kt`
|
|
||||||
|
|
||||||
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Service["JellyTauPlaybackService"]
|
|
||||||
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
|
|
||||||
|
|
||||||
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
|
|
||||||
|
|
||||||
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
|
|
||||||
|
|
||||||
MediaSessionCompat --> VolumeProvider
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Hardware["System"]
|
|
||||||
VolumeButtons["Hardware Volume Buttons"]
|
|
||||||
Lockscreen["Lockscreen Controls"]
|
|
||||||
Notification["Media Notification"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Rust["Rust Backend"]
|
|
||||||
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
|
|
||||||
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
|
|
||||||
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
|
|
||||||
end
|
|
||||||
|
|
||||||
VolumeButtons --> VolumeProvider
|
|
||||||
Lockscreen --> MediaSession
|
|
||||||
Notification --> MediaSession
|
|
||||||
|
|
||||||
VolumeProvider --> JNI
|
|
||||||
JNI --> PlaybackMode
|
|
||||||
PlaybackMode --> JellyfinAPI
|
|
||||||
```
|
|
||||||
|
|
||||||
**Architecture Rationale:**
|
|
||||||
|
|
||||||
JellyTau maintains both MediaSession types because they serve different purposes:
|
|
||||||
|
|
||||||
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
|
|
||||||
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
|
|
||||||
|
|
||||||
When in remote playback mode (controlling a Jellyfin session on another device):
|
|
||||||
- Volume buttons are routed through `VolumeProviderCompat`
|
|
||||||
- Volume changes are sent to the remote session via Jellyfin API
|
|
||||||
- System volume UI shows the remote session's volume level
|
|
||||||
|
|
||||||
**Remote Volume Flow:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant VolumeButton as Hardware Volume Button
|
|
||||||
participant VolumeProvider as VolumeProviderCompat
|
|
||||||
participant JNI as nativeOnRemoteVolumeChange
|
|
||||||
participant PlaybackMode as PlaybackModeManager
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
participant RemoteSession as Remote Session (TV/Browser)
|
|
||||||
|
|
||||||
User->>VolumeButton: Press Volume Up
|
|
||||||
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
|
|
||||||
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
|
|
||||||
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
|
|
||||||
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
|
|
||||||
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
|
|
||||||
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
|
|
||||||
Jellyfin->>RemoteSession: Set volume to new level
|
|
||||||
RemoteSession-->>User: Volume changes on TV/Browser
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
|
|
||||||
**Enabling Remote Volume** (`enableRemoteVolume()`):
|
|
||||||
```kotlin
|
|
||||||
fun enableRemoteVolume(initialVolume: Int) {
|
|
||||||
volumeProvider = object : VolumeProviderCompat(
|
|
||||||
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
|
|
||||||
100, // Max volume
|
|
||||||
initialVolume
|
|
||||||
) {
|
|
||||||
override fun onSetVolumeTo(volume: Int) {
|
|
||||||
remoteVolumeLevel = volume.coerceIn(0, 100)
|
|
||||||
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAdjustVolume(direction: Int) {
|
|
||||||
when (direction) {
|
|
||||||
AudioManager.ADJUST_RAISE -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
|
||||||
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
AudioManager.ADJUST_LOWER -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
|
||||||
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Disabling Remote Volume** (`disableRemoteVolume()`):
|
|
||||||
```kotlin
|
|
||||||
fun disableRemoteVolume() {
|
|
||||||
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
|
||||||
volumeProvider = null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
|
|
||||||
```rust
|
|
||||||
/// Enable remote volume control on Android
|
|
||||||
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
|
||||||
start_playback_service()?;
|
|
||||||
let service_instance = get_playback_service_instance()?;
|
|
||||||
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
|
|
||||||
&[JValue::Int(initial_volume)])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependencies** (`src-tauri/android/build.gradle.kts`):
|
|
||||||
```kotlin
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
|
|
||||||
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Integration with Playback Mode:**
|
|
||||||
|
|
||||||
Remote volume is automatically enabled/disabled during playback mode transfers:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In PlaybackModeManager::transfer_to_remote()
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
|
||||||
log::warn!("Failed to enable remote volume: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In PlaybackModeManager::transfer_to_local()
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::disable_remote_volume() {
|
|
||||||
log::warn!("Failed to disable remote volume: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android Album Art Caching
|
|
||||||
|
|
||||||
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
|
|
||||||
|
|
||||||
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
|
||||||
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
|
||||||
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Cache["AlbumArtCache.kt"]
|
|
||||||
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
|
|
||||||
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
|
|
||||||
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Service["JellyTauPlaybackService.kt"]
|
|
||||||
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
|
|
||||||
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LoadMedia --> AsyncDownload
|
|
||||||
AsyncDownload --> MemoryCache
|
|
||||||
MemoryCache --> Download
|
|
||||||
Download --> ErrorHandle
|
|
||||||
AsyncDownload --> UpdateMeta
|
|
||||||
UpdateMeta --> Notification
|
|
||||||
```
|
|
||||||
|
|
||||||
**AlbumArtCache Singleton:**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
class AlbumArtCache(context: Context) {
|
|
||||||
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
|
||||||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
|
||||||
return bitmap.byteCount / 1024 // Size in KB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getArtwork(url: String): Bitmap? {
|
|
||||||
memoryCache.get(url)?.let { return it }
|
|
||||||
return downloadAndCache(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun downloadAndCache(url: String): Bitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
// HTTP download with 5s timeout
|
|
||||||
// Scale to 512x512 max
|
|
||||||
// Auto-evict LRU if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Integration Flow:**
|
|
||||||
|
|
||||||
1. **Track Load** (`loadWithMetadata()`):
|
|
||||||
- Store artwork URL in `currentArtworkUrl`
|
|
||||||
- Reset bitmap to null
|
|
||||||
- Start playback immediately (non-blocking)
|
|
||||||
|
|
||||||
2. **Async Download** (Background Coroutine):
|
|
||||||
- Check cache: instant hit if available
|
|
||||||
- Network miss: download, scale, cache
|
|
||||||
- Auto-retry on network failure with exponential backoff
|
|
||||||
- Graceful fallback if artwork unavailable
|
|
||||||
|
|
||||||
3. **Notification Update**:
|
|
||||||
- Pass bitmap to `updatePlaybackServiceNotification()`
|
|
||||||
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
|
|
||||||
- Display as large icon in notification
|
|
||||||
- Show on lock screen
|
|
||||||
|
|
||||||
**Memory Management:**
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| Cache Size | 1/8 of heap (12-16MB typical) |
|
|
||||||
| Max Resolution | 512x512 pixels |
|
|
||||||
| Capacity | ~50-100 album arts |
|
|
||||||
| Eviction Policy | LRU (Least Recently Used) |
|
|
||||||
| Lifetime | In-memory only (app session) |
|
|
||||||
| Network Timeout | 5 seconds per download |
|
|
||||||
|
|
||||||
**Performance Characteristics:**
|
|
||||||
|
|
||||||
- **Cache Hit**: ~1ms (in-memory retrieval)
|
|
||||||
- **Cache Miss**: ~200-500ms (download + scale)
|
|
||||||
- **Playback Impact**: Zero (async downloads)
|
|
||||||
- **Memory Overhead**: Max 16MB (auto-eviction)
|
|
||||||
- **Error Recovery**: Automatic with exponential backoff
|
|
||||||
|
|
||||||
## Backend Initialization
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/lib.rs`
|
|
||||||
|
|
||||||
Backend selection is platform-specific:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
|
|
||||||
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
match MpvBackend::new(event_emitter.clone()) {
|
|
||||||
Ok(backend) => return Box::new(backend),
|
|
||||||
Err(e) => eprintln!("MPV init failed: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
// ExoPlayer requires Activity context, initialized separately
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
Box::new(NullBackend::new())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
# Download Manager & Offline Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/`
|
|
||||||
|
|
||||||
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
DownloadButton["DownloadButton.svelte"]
|
|
||||||
DownloadsPage["/downloads"]
|
|
||||||
DownloadsStore["downloads.ts store"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Backend["Rust Backend"]
|
|
||||||
Commands["Download Commands"]
|
|
||||||
DownloadManager["DownloadManager"]
|
|
||||||
DownloadWorker["DownloadWorker"]
|
|
||||||
SmartCache["SmartCache Engine"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Storage["Storage"]
|
|
||||||
SQLite[("SQLite DB")]
|
|
||||||
MediaFiles[("Downloaded Files")]
|
|
||||||
end
|
|
||||||
|
|
||||||
DownloadButton -->|"invoke('download_item')"| Commands
|
|
||||||
DownloadsPage -->|"invoke('get_downloads')"| Commands
|
|
||||||
Commands --> DownloadManager
|
|
||||||
DownloadManager --> DownloadWorker
|
|
||||||
DownloadManager --> SmartCache
|
|
||||||
DownloadWorker -->|"HTTP Stream"| MediaFiles
|
|
||||||
DownloadWorker -->|"Events"| DownloadsStore
|
|
||||||
Commands <--> SQLite
|
|
||||||
SmartCache <--> SQLite
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download Worker
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/worker.rs`
|
|
||||||
|
|
||||||
The download worker handles HTTP streaming with retry logic and resume support:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct DownloadWorker {
|
|
||||||
client: reqwest::Client,
|
|
||||||
max_retries: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DownloadTask {
|
|
||||||
pub id: i64,
|
|
||||||
pub item_id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub priority: i32,
|
|
||||||
pub url: String,
|
|
||||||
pub target_path: PathBuf,
|
|
||||||
pub mime_type: Option<String>,
|
|
||||||
pub expected_size: Option<i64>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Retry Strategy**:
|
|
||||||
- Exponential backoff: 5s, 15s, 45s
|
|
||||||
- Maximum 3 retry attempts
|
|
||||||
- HTTP Range requests for resume support
|
|
||||||
- Progress events emitted every 1MB
|
|
||||||
|
|
||||||
**Download Flow**:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI
|
|
||||||
participant Command as download_item
|
|
||||||
participant DB as SQLite
|
|
||||||
participant Worker as DownloadWorker
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
participant Store as downloads store
|
|
||||||
|
|
||||||
UI->>Command: download_item(itemId, userId)
|
|
||||||
Command->>DB: INSERT INTO downloads
|
|
||||||
Command->>Worker: Start download task
|
|
||||||
Worker->>Jellyfin: GET /Items/{id}/Download
|
|
||||||
|
|
||||||
loop Progress Updates
|
|
||||||
Jellyfin->>Worker: Stream chunks
|
|
||||||
Worker->>Worker: Write to .part file
|
|
||||||
Worker->>Store: Emit progress event
|
|
||||||
Store->>UI: Update progress bar
|
|
||||||
end
|
|
||||||
|
|
||||||
Worker->>Worker: Rename .part to final
|
|
||||||
Worker->>DB: UPDATE status='completed'
|
|
||||||
Worker->>Store: Emit completed event
|
|
||||||
Store->>UI: Show completed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smart Caching Engine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/cache.rs`
|
|
||||||
|
|
||||||
The smart caching system provides predictive downloads based on listening patterns:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SmartCache {
|
|
||||||
config: Arc<Mutex<CacheConfig>>,
|
|
||||||
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CacheConfig {
|
|
||||||
pub queue_precache_enabled: bool,
|
|
||||||
pub queue_precache_count: usize, // Default: 5
|
|
||||||
pub album_affinity_enabled: bool,
|
|
||||||
pub album_affinity_threshold: usize, // Default: 3
|
|
||||||
pub storage_limit: u64, // Default: 10GB
|
|
||||||
pub wifi_only: bool, // Default: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Caching Strategies**:
|
|
||||||
|
|
||||||
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
|
|
||||||
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
|
|
||||||
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
|
|
||||||
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
|
|
||||||
|
|
||||||
Play --> TrackHistory["Track Play History"]
|
|
||||||
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
|
|
||||||
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
|
|
||||||
|
|
||||||
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
|
|
||||||
CacheAlbum --> CheckStorage
|
|
||||||
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
|
|
||||||
CheckStorage -->|"OK"| Download["Queue Download"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download Commands
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/download.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Description |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
|
|
||||||
| `download_album` | `album_id, user_id` | Queue all tracks in album |
|
|
||||||
| `get_downloads` | `user_id, status_filter` | Get download list |
|
|
||||||
| `pause_download` | `download_id` | Pause active download |
|
|
||||||
| `resume_download` | `download_id` | Resume paused download |
|
|
||||||
| `cancel_download` | `download_id` | Cancel and delete partial |
|
|
||||||
| `delete_download` | `download_id` | Delete completed download |
|
|
||||||
|
|
||||||
## Offline Commands
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/offline.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Description |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| `offline_is_available` | `item_id` | Check if item downloaded |
|
|
||||||
| `offline_get_items` | `user_id` | Get all offline items |
|
|
||||||
| `offline_search` | `user_id, query` | Search downloaded items |
|
|
||||||
|
|
||||||
## Player Integration
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/player.rs` (modified)
|
|
||||||
|
|
||||||
The player checks for local downloads before streaming:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
|
|
||||||
let local_path = db.and_then(|db_wrapper| {
|
|
||||||
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
|
|
||||||
});
|
|
||||||
|
|
||||||
let source = if let Some(path) = local_path {
|
|
||||||
MediaSource::Local {
|
|
||||||
file_path: PathBuf::from(path),
|
|
||||||
jellyfin_item_id: Some(jellyfin_id.clone())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
MediaSource::Remote {
|
|
||||||
stream_url: req.stream_url,
|
|
||||||
jellyfin_item_id: jellyfin_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
MediaItem { source, /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Downloads Store
|
|
||||||
|
|
||||||
**Location**: `src/lib/stores/downloads.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface DownloadsState {
|
|
||||||
downloads: Record<number, DownloadInfo>;
|
|
||||||
activeCount: number;
|
|
||||||
queuedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloads = createDownloadsStore();
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
downloads.downloadItem(itemId, userId, filePath)
|
|
||||||
downloads.downloadAlbum(albumId, userId)
|
|
||||||
downloads.pause(downloadId)
|
|
||||||
downloads.resume(downloadId)
|
|
||||||
downloads.cancel(downloadId)
|
|
||||||
downloads.delete(downloadId)
|
|
||||||
downloads.refresh(userId, statusFilter)
|
|
||||||
|
|
||||||
// Derived stores
|
|
||||||
export const activeDownloads = derived(downloads, ($d) =>
|
|
||||||
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Handling**:
|
|
||||||
|
|
||||||
The store listens to Tauri events for real-time updates:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
listen<DownloadEvent>('download-event', (event) => {
|
|
||||||
const payload = event.payload;
|
|
||||||
|
|
||||||
switch (payload.type) {
|
|
||||||
case 'started':
|
|
||||||
// Update status to 'downloading'
|
|
||||||
case 'progress':
|
|
||||||
// Update progress and bytes_downloaded
|
|
||||||
case 'completed':
|
|
||||||
// Update status to 'completed', progress to 1.0
|
|
||||||
case 'failed':
|
|
||||||
// Update status to 'failed', store error message
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download UI Components
|
|
||||||
|
|
||||||
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
|
|
||||||
- Multiple states: available, downloading, completed, failed, paused
|
|
||||||
- Circular progress ring during download
|
|
||||||
- Size variants: sm, md, lg
|
|
||||||
- Integrated into TrackList with `showDownload={true}` prop
|
|
||||||
|
|
||||||
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
|
|
||||||
- Individual download list item with progress bar
|
|
||||||
- Action buttons: pause, resume, cancel, delete
|
|
||||||
- Status indicators with color coding
|
|
||||||
|
|
||||||
**Downloads Page** (`src/routes/downloads/+page.svelte`):
|
|
||||||
- Active/Completed tabs
|
|
||||||
- Bulk actions: Pause All, Resume All, Clear Completed
|
|
||||||
- Empty states with helpful instructions
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
**downloads table**:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
file_path TEXT,
|
|
||||||
file_size INTEGER,
|
|
||||||
mime_type TEXT,
|
|
||||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
|
||||||
progress REAL DEFAULT 0.0,
|
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
|
||||||
priority INTEGER DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_downloads_queue
|
|
||||||
ON downloads(status, priority DESC, queued_at ASC)
|
|
||||||
WHERE status IN ('pending', 'downloading');
|
|
||||||
```
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Connectivity & Network Architecture
|
|
||||||
|
|
||||||
## HTTP Client with Retry Logic
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/jellyfin/http_client.rs`
|
|
||||||
|
|
||||||
The HTTP client provides automatic retry with exponential backoff for network resilience:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct HttpClient {
|
|
||||||
client: reqwest::Client,
|
|
||||||
config: HttpConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct HttpConfig {
|
|
||||||
pub timeout: Duration, // Default: 30s (large library queries can be slow)
|
|
||||||
pub max_retries: u32, // Default: 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
|
|
||||||
|
|
||||||
**Retry Strategy:**
|
|
||||||
- Retry delays: 1s, 2s, 4s (exponential backoff)
|
|
||||||
- Retries on: Network errors, 5xx server errors
|
|
||||||
- No retry on: 4xx client errors, 401/403 authentication errors
|
|
||||||
|
|
||||||
**Error Classification:**
|
|
||||||
```rust
|
|
||||||
pub enum ErrorKind {
|
|
||||||
Network, // Connection failures, timeouts, DNS errors
|
|
||||||
Authentication, // 401/403 responses
|
|
||||||
Server, // 5xx server errors
|
|
||||||
Client, // Other 4xx errors
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connectivity Monitor
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/connectivity/mod.rs`
|
|
||||||
|
|
||||||
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
|
|
||||||
|
|
||||||
### Source of truth: repository traffic
|
|
||||||
|
|
||||||
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
|
|
||||||
|
|
||||||
| Repository outcome | Meaning | Effect on reachability |
|
|
||||||
|--------------------|---------|------------------------|
|
|
||||||
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
|
|
||||||
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
|
|
||||||
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
|
|
||||||
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
|
|
||||||
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
|
|
||||||
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
|
|
||||||
|
|
||||||
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
|
|
||||||
|
|
||||||
### Time-window debounce (offline) + instant recovery (online)
|
|
||||||
|
|
||||||
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
|
|
||||||
|
|
||||||
- On the **first** `Network` failure, the monitor records `first_failure_at`.
|
|
||||||
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
|
|
||||||
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
|
|
||||||
|
|
||||||
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
|
|
||||||
|
|
||||||
### Offline-only recovery probe
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
|
|
||||||
Monitor --> State{"is_server_reachable?"}
|
|
||||||
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
|
|
||||||
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
|
|
||||||
Probe -->|"reachable again"| Monitor
|
|
||||||
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
|
|
||||||
Emit --> Frontend["Frontend Store → banner"]
|
|
||||||
```
|
|
||||||
|
|
||||||
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Traffic-driven**: Reachability follows the requests the user actually makes.
|
|
||||||
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
|
|
||||||
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
|
|
||||||
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
|
|
||||||
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
|
|
||||||
|
|
||||||
**Tauri Commands:**
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
|
|
||||||
| `connectivity_set_server_url` | Update monitored server URL |
|
|
||||||
| `connectivity_get_status` | Get current connectivity status |
|
|
||||||
| `connectivity_start_monitoring` | Start the offline recovery probe |
|
|
||||||
| `connectivity_stop_monitoring` | Stop the probe |
|
|
||||||
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
|
|
||||||
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
|
|
||||||
|
|
||||||
**Frontend Integration:**
|
|
||||||
```typescript
|
|
||||||
// The store is a pure reflection of backend events — it no longer decides
|
|
||||||
// reachability itself. navigator.onLine is advisory: it triggers an immediate
|
|
||||||
// recheck rather than forcing the offline state.
|
|
||||||
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
|
||||||
updateConnectivityState(event.payload.isReachable);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Network Resilience Architecture
|
|
||||||
|
|
||||||
The connectivity system provides resilience through multiple layers:
|
|
||||||
|
|
||||||
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
|
||||||
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
|
|
||||||
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
|
|
||||||
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
|
|
||||||
|
|
||||||
**Design Principles:**
|
|
||||||
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
|
|
||||||
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
|
|
||||||
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
|
|
||||||
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
|
|
||||||
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
|
|
||||||
- **Event-Driven**: Frontend reacts to connectivity changes via events.
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
# Offline Database Design
|
|
||||||
|
|
||||||
## Entity Relationship Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
servers ||--o{ users : "has"
|
|
||||||
servers ||--o{ libraries : "has"
|
|
||||||
libraries ||--o{ items : "contains"
|
|
||||||
items ||--o{ items : "parent_of"
|
|
||||||
items ||--o{ user_data : "has"
|
|
||||||
items ||--o{ downloads : "has"
|
|
||||||
items ||--o{ media_streams : "has"
|
|
||||||
items ||--o{ thumbnails : "has"
|
|
||||||
users ||--o{ user_data : "owns"
|
|
||||||
users ||--o{ downloads : "owns"
|
|
||||||
users ||--o{ sync_queue : "owns"
|
|
||||||
|
|
||||||
servers {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id UK
|
|
||||||
string name
|
|
||||||
string url
|
|
||||||
string version
|
|
||||||
datetime last_sync
|
|
||||||
}
|
|
||||||
|
|
||||||
users {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
string name
|
|
||||||
boolean is_active
|
|
||||||
}
|
|
||||||
|
|
||||||
libraries {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
string name
|
|
||||||
string collection_type
|
|
||||||
string image_tag
|
|
||||||
}
|
|
||||||
|
|
||||||
items {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
int library_id FK
|
|
||||||
int parent_id FK
|
|
||||||
string type
|
|
||||||
string name
|
|
||||||
string sort_name
|
|
||||||
string overview
|
|
||||||
int production_year
|
|
||||||
float community_rating
|
|
||||||
string official_rating
|
|
||||||
int runtime_ticks
|
|
||||||
string primary_image_tag
|
|
||||||
string backdrop_image_tag
|
|
||||||
string album_id
|
|
||||||
string album_name
|
|
||||||
string album_artist
|
|
||||||
json artists
|
|
||||||
json genres
|
|
||||||
int index_number
|
|
||||||
int parent_index_number
|
|
||||||
string premiere_date
|
|
||||||
json metadata_json
|
|
||||||
datetime created_at
|
|
||||||
datetime updated_at
|
|
||||||
datetime last_sync
|
|
||||||
}
|
|
||||||
|
|
||||||
user_data {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int user_id FK
|
|
||||||
int position_ticks
|
|
||||||
int play_count
|
|
||||||
boolean is_favorite
|
|
||||||
boolean played
|
|
||||||
datetime last_played
|
|
||||||
datetime updated_at
|
|
||||||
datetime synced_at
|
|
||||||
}
|
|
||||||
|
|
||||||
downloads {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int user_id FK
|
|
||||||
string file_path
|
|
||||||
int file_size
|
|
||||||
string status
|
|
||||||
float progress
|
|
||||||
int priority
|
|
||||||
string error_message
|
|
||||||
datetime created_at
|
|
||||||
datetime completed_at
|
|
||||||
}
|
|
||||||
|
|
||||||
media_streams {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int stream_index
|
|
||||||
string type
|
|
||||||
string codec
|
|
||||||
string language
|
|
||||||
string display_title
|
|
||||||
boolean is_default
|
|
||||||
boolean is_forced
|
|
||||||
boolean is_external
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_queue {
|
|
||||||
int id PK
|
|
||||||
int user_id FK
|
|
||||||
string operation
|
|
||||||
string entity_type
|
|
||||||
string entity_id
|
|
||||||
json payload
|
|
||||||
datetime created_at
|
|
||||||
int attempts
|
|
||||||
datetime last_attempt
|
|
||||||
string status
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnails {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
string image_type
|
|
||||||
string image_tag
|
|
||||||
string file_path
|
|
||||||
int width
|
|
||||||
int height
|
|
||||||
datetime cached_at
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Definitions
|
|
||||||
|
|
||||||
### servers
|
|
||||||
Stores connected Jellyfin server information.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE servers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
version TEXT,
|
|
||||||
last_sync DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### users
|
|
||||||
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### libraries
|
|
||||||
Stores library/collection metadata.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE libraries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
collection_type TEXT,
|
|
||||||
image_tag TEXT,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
last_sync DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_libraries_server ON libraries(server_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### items
|
|
||||||
Main table for all media items (movies, episodes, albums, songs, etc.).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
|
|
||||||
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Basic metadata
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sort_name TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
|
|
||||||
-- Media info
|
|
||||||
production_year INTEGER,
|
|
||||||
community_rating REAL,
|
|
||||||
official_rating TEXT,
|
|
||||||
runtime_ticks INTEGER,
|
|
||||||
|
|
||||||
-- Images
|
|
||||||
primary_image_tag TEXT,
|
|
||||||
backdrop_image_tag TEXT,
|
|
||||||
|
|
||||||
-- Audio-specific
|
|
||||||
album_id TEXT,
|
|
||||||
album_name TEXT,
|
|
||||||
album_artist TEXT,
|
|
||||||
artists TEXT, -- JSON array
|
|
||||||
|
|
||||||
-- Series/Season-specific
|
|
||||||
index_number INTEGER,
|
|
||||||
parent_index_number INTEGER,
|
|
||||||
series_id TEXT,
|
|
||||||
series_name TEXT,
|
|
||||||
season_id TEXT,
|
|
||||||
|
|
||||||
-- Additional
|
|
||||||
genres TEXT, -- JSON array
|
|
||||||
premiere_date TEXT,
|
|
||||||
metadata_json TEXT,
|
|
||||||
|
|
||||||
-- Sync tracking
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_sync DATETIME,
|
|
||||||
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Performance indexes
|
|
||||||
CREATE INDEX idx_items_server ON items(server_id);
|
|
||||||
CREATE INDEX idx_items_library ON items(library_id);
|
|
||||||
CREATE INDEX idx_items_parent ON items(parent_id);
|
|
||||||
CREATE INDEX idx_items_type ON items(type);
|
|
||||||
CREATE INDEX idx_items_album ON items(album_id);
|
|
||||||
CREATE INDEX idx_items_series ON items(series_id);
|
|
||||||
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
|
|
||||||
|
|
||||||
-- Full-text search
|
|
||||||
CREATE VIRTUAL TABLE items_fts USING fts5(
|
|
||||||
name,
|
|
||||||
overview,
|
|
||||||
artists,
|
|
||||||
album_name,
|
|
||||||
album_artist,
|
|
||||||
content='items',
|
|
||||||
content_rowid='id'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS in sync
|
|
||||||
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
|
|
||||||
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
|
|
||||||
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
|
|
||||||
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
|
||||||
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
|
||||||
END;
|
|
||||||
```
|
|
||||||
|
|
||||||
### media_streams
|
|
||||||
Stores subtitle and audio track information for items.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE media_streams (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
stream_index INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
codec TEXT,
|
|
||||||
language TEXT,
|
|
||||||
display_title TEXT,
|
|
||||||
is_default BOOLEAN DEFAULT 0,
|
|
||||||
is_forced BOOLEAN DEFAULT 0,
|
|
||||||
is_external BOOLEAN DEFAULT 0,
|
|
||||||
path TEXT,
|
|
||||||
UNIQUE(item_id, stream_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### user_data
|
|
||||||
Stores per-user data for items (favorites, progress, play count).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE user_data (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Playback state
|
|
||||||
position_ticks INTEGER DEFAULT 0,
|
|
||||||
play_count INTEGER DEFAULT 0,
|
|
||||||
played BOOLEAN DEFAULT 0,
|
|
||||||
last_played DATETIME,
|
|
||||||
|
|
||||||
-- User preferences
|
|
||||||
is_favorite BOOLEAN DEFAULT 0,
|
|
||||||
user_rating REAL,
|
|
||||||
|
|
||||||
-- Sync tracking
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
synced_at DATETIME,
|
|
||||||
needs_sync BOOLEAN DEFAULT 0,
|
|
||||||
|
|
||||||
UNIQUE(item_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_data_item ON user_data(item_id);
|
|
||||||
CREATE INDEX idx_user_data_user ON user_data(user_id);
|
|
||||||
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
|
|
||||||
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
|
|
||||||
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
|
|
||||||
WHERE position_ticks > 0 AND played = 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### downloads
|
|
||||||
Tracks downloaded media files.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
file_path TEXT,
|
|
||||||
file_size INTEGER,
|
|
||||||
file_hash TEXT,
|
|
||||||
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
progress REAL DEFAULT 0,
|
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
transcode_profile TEXT,
|
|
||||||
|
|
||||||
priority INTEGER DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
started_at DATETIME,
|
|
||||||
completed_at DATETIME,
|
|
||||||
expires_at DATETIME,
|
|
||||||
|
|
||||||
UNIQUE(item_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_downloads_status ON downloads(status);
|
|
||||||
CREATE INDEX idx_downloads_user ON downloads(user_id);
|
|
||||||
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
|
|
||||||
WHERE status IN ('pending', 'downloading');
|
|
||||||
```
|
|
||||||
|
|
||||||
### sync_queue
|
|
||||||
Stores mutations to sync back to server when online.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE sync_queue (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
operation TEXT NOT NULL,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
payload TEXT,
|
|
||||||
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
attempts INTEGER DEFAULT 0,
|
|
||||||
max_attempts INTEGER DEFAULT 5,
|
|
||||||
last_attempt DATETIME,
|
|
||||||
error_message TEXT,
|
|
||||||
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at DATETIME
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
|
|
||||||
WHERE status = 'pending';
|
|
||||||
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### thumbnails
|
|
||||||
Caches downloaded artwork.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
image_type TEXT NOT NULL,
|
|
||||||
image_tag TEXT,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
file_size INTEGER,
|
|
||||||
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(item_id, image_type, width)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
|
|
||||||
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
|
||||||
```
|
|
||||||
|
|
||||||
### playlists (for local/synced playlists)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE playlists (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_local_only BOOLEAN DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
synced_at DATETIME,
|
|
||||||
needs_sync BOOLEAN DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE playlist_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
sort_order INTEGER NOT NULL,
|
|
||||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(playlist_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Queries
|
|
||||||
|
|
||||||
### Get items for offline library browsing
|
|
||||||
```sql
|
|
||||||
-- Get all albums in a music library
|
|
||||||
SELECT * FROM items
|
|
||||||
WHERE library_id = ? AND type = 'MusicAlbum'
|
|
||||||
ORDER BY sort_name;
|
|
||||||
|
|
||||||
-- Get tracks for an album
|
|
||||||
SELECT * FROM items
|
|
||||||
WHERE album_id = ? AND type = 'Audio'
|
|
||||||
ORDER BY parent_index_number, index_number;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resume / Continue Watching
|
|
||||||
```sql
|
|
||||||
SELECT i.*, ud.position_ticks, ud.last_played
|
|
||||||
FROM items i
|
|
||||||
JOIN user_data ud ON ud.item_id = i.id
|
|
||||||
WHERE ud.user_id = ?
|
|
||||||
AND ud.position_ticks > 0
|
|
||||||
AND ud.played = 0
|
|
||||||
ORDER BY ud.last_played DESC
|
|
||||||
LIMIT 20;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Offline search
|
|
||||||
```sql
|
|
||||||
SELECT i.* FROM items i
|
|
||||||
JOIN items_fts fts ON fts.rowid = i.id
|
|
||||||
WHERE items_fts MATCH ?
|
|
||||||
ORDER BY rank;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Download queue management
|
|
||||||
```sql
|
|
||||||
-- Get next item to download
|
|
||||||
SELECT d.*, i.name, i.type
|
|
||||||
FROM downloads d
|
|
||||||
JOIN items i ON i.id = d.item_id
|
|
||||||
WHERE d.status = 'pending'
|
|
||||||
ORDER BY d.priority DESC, d.created_at ASC
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- Get download progress for UI
|
|
||||||
SELECT
|
|
||||||
d.status,
|
|
||||||
COUNT(*) as count,
|
|
||||||
SUM(d.file_size) as total_size,
|
|
||||||
SUM(d.bytes_downloaded) as downloaded
|
|
||||||
FROM downloads d
|
|
||||||
WHERE d.user_id = ?
|
|
||||||
GROUP BY d.status;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync queue processing
|
|
||||||
```sql
|
|
||||||
-- Get pending sync operations (oldest first)
|
|
||||||
SELECT * FROM sync_queue
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND attempts < max_attempts
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 10;
|
|
||||||
|
|
||||||
-- Mark operation complete
|
|
||||||
UPDATE sync_queue
|
|
||||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Online Mode
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph OnlineMode["Online Mode"]
|
|
||||||
JellyfinServer["Jellyfin Server"]
|
|
||||||
OnlineRepo["OnlineRepo"]
|
|
||||||
SQLite["SQLite"]
|
|
||||||
HybridRepo["HybridRepository"]
|
|
||||||
UI["UI / Stores"]
|
|
||||||
|
|
||||||
JellyfinServer -->|"API Response"| OnlineRepo
|
|
||||||
OnlineRepo -->|"Cache"| SQLite
|
|
||||||
SQLite -->|"Sync"| JellyfinServer
|
|
||||||
OnlineRepo -->|"Response"| HybridRepo
|
|
||||||
SQLite -->|"Fallback"| HybridRepo
|
|
||||||
HybridRepo --> UI
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Offline Mode
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph OfflineMode["Offline Mode"]
|
|
||||||
OfflineRepo["OfflineRepo"]
|
|
||||||
SQLite2["SQLite"]
|
|
||||||
SyncQueue["sync_queue<br/>(Queued for later)"]
|
|
||||||
HybridRepo2["HybridRepository"]
|
|
||||||
UI2["UI / Stores"]
|
|
||||||
|
|
||||||
OfflineRepo <-->|"Query"| SQLite2
|
|
||||||
SQLite2 -->|"Mutations"| SyncQueue
|
|
||||||
OfflineRepo --> HybridRepo2
|
|
||||||
HybridRepo2 --> UI2
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync on Reconnect
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
NetworkRestored["Network restored"]
|
|
||||||
SyncService["SyncService"]
|
|
||||||
SyncQueue2["sync_queue"]
|
|
||||||
JellyfinAPI["Jellyfin API"]
|
|
||||||
MarkSynced["Mark synced"]
|
|
||||||
|
|
||||||
NetworkRestored --> SyncService
|
|
||||||
SyncService -->|"Read"| SyncQueue2
|
|
||||||
SyncQueue2 -->|"Send"| JellyfinAPI
|
|
||||||
JellyfinAPI -->|"Success"| MarkSynced
|
|
||||||
MarkSynced --> SyncService
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage Estimates
|
|
||||||
|
|
||||||
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|
|
||||||
|--------------|---------------|----------------|------------|
|
|
||||||
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
|
|
||||||
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
|
|
||||||
| Movie | ~5 KB | ~200 KB | 1-8 GB |
|
|
||||||
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
|
||||||
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
|
||||||
|
|
||||||
## Rust Module Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/storage/
|
|
||||||
├── mod.rs # Module exports, Database struct
|
|
||||||
├── schema.rs # Table definitions, migrations
|
|
||||||
├── models.rs # Rust structs matching tables
|
|
||||||
├── queries/
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── items.rs # Item CRUD operations
|
|
||||||
│ ├── user_data.rs # User data operations
|
|
||||||
│ ├── downloads.rs # Download queue operations
|
|
||||||
│ └── sync.rs # Sync queue operations
|
|
||||||
└── sync/
|
|
||||||
├── mod.rs # SyncService
|
|
||||||
├── manager.rs # Background sync manager
|
|
||||||
└── operations.rs # Individual sync operation handlers
|
|
||||||
```
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
## Authentication Token Storage
|
|
||||||
|
|
||||||
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
LoginSuccess["Login Success"]
|
|
||||||
KeyringCheck{"System Keyring<br/>Available?"}
|
|
||||||
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
|
|
||||||
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
|
|
||||||
|
|
||||||
LoginSuccess --> KeyringCheck
|
|
||||||
KeyringCheck -->|"Yes"| OSCredential
|
|
||||||
KeyringCheck -->|"No"| EncryptedFallback
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Format:**
|
|
||||||
```
|
|
||||||
jellytau::{server_id}::{user_id}::access_token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale:**
|
|
||||||
- Tokens in SQLite would be readable if the database file is accessed
|
|
||||||
- System keyrings provide OS-level encryption and access control
|
|
||||||
- Fallback ensures functionality on minimal systems without a keyring daemon
|
|
||||||
|
|
||||||
## Secure Storage Module
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/secure_storage/` (planned)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait SecureStorage: Send + Sync {
|
|
||||||
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
|
|
||||||
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
|
|
||||||
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform implementations
|
|
||||||
pub struct KeyringStorage; // Uses keyring crate
|
|
||||||
pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
|
||||||
```
|
|
||||||
|
|
||||||
## Network Security
|
|
||||||
|
|
||||||
| Aspect | Implementation |
|
|
||||||
|--------|----------------|
|
|
||||||
| Transport | HTTPS required for all Jellyfin API calls |
|
|
||||||
| Certificate Validation | System CA store (configurable for self-signed) |
|
|
||||||
| Token Transmission | Bearer token in `Authorization` header only |
|
|
||||||
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
|
||||||
|
|
||||||
## Local Data Protection
|
|
||||||
|
|
||||||
| Data Type | Protection |
|
|
||||||
|-----------|------------|
|
|
||||||
| Access Tokens | System keyring or encrypted file |
|
|
||||||
| Database (SQLite) | Plaintext (metadata only, no secrets) |
|
|
||||||
| Downloaded Media | Filesystem permissions only |
|
|
||||||
| Cached Thumbnails | Filesystem permissions only |
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
|
|
||||||
2. **Token Isolation**: Each user/server combination has a separate token entry
|
|
||||||
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
|
||||||
4. **No Token Logging**: Tokens are never written to logs or debug output
|
|
||||||
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
# JellyTau Software Architecture
|
|
||||||
|
|
||||||
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
|
||||||
|
|
||||||
**Last Updated:** 2026-06-20
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
|
|
||||||
|
|
||||||
### Architecture Principles
|
|
||||||
|
|
||||||
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
|
||||||
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
|
||||||
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
|
||||||
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
|
|
||||||
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
|
||||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
|
||||||
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
|
|
||||||
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
|
||||||
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Frontend["Svelte Frontend"]
|
|
||||||
subgraph Stores["Stores (Thin Wrappers)"]
|
|
||||||
auth["auth"]
|
|
||||||
player["player"]
|
|
||||||
queue["queue"]
|
|
||||||
library["library"]
|
|
||||||
connectivity["connectivity"]
|
|
||||||
playbackMode["playbackMode"]
|
|
||||||
end
|
|
||||||
subgraph Components
|
|
||||||
playerComp["player/"]
|
|
||||||
libraryComp["library/"]
|
|
||||||
Search["Search"]
|
|
||||||
end
|
|
||||||
subgraph Routes
|
|
||||||
routeLibrary["/library"]
|
|
||||||
routePlayer["/player"]
|
|
||||||
routeRoot["/"]
|
|
||||||
end
|
|
||||||
subgraph API["API Layer (Thin Client)"]
|
|
||||||
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
|
|
||||||
JellyfinClient["JellyfinClient<br/>(Helper)"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Frontend -->|"Tauri IPC (invoke)"| Backend
|
|
||||||
|
|
||||||
subgraph Backend["Rust Backend (Business Logic)"]
|
|
||||||
subgraph Commands["Tauri Commands (90+)"]
|
|
||||||
PlayerCmds["player.rs"]
|
|
||||||
RepoCmds["repository.rs (27)"]
|
|
||||||
PlaybackModeCmds["playback_mode.rs (5)"]
|
|
||||||
StorageCmds["storage.rs"]
|
|
||||||
ConnectivityCmds["connectivity.rs (7)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Core["Core Modules"]
|
|
||||||
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
|
|
||||||
|
|
||||||
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
|
|
||||||
|
|
||||||
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
|
|
||||||
|
|
||||||
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
|
|
||||||
|
|
||||||
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
|
|
||||||
|
|
||||||
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Storage["Storage Layer"]
|
|
||||||
DatabaseService["DatabaseService<br/>(Async trait)"]
|
|
||||||
SQLite["SQLite Database<br/>(13 tables)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
Commands --> Core
|
|
||||||
Core --> Storage
|
|
||||||
Repository --> HttpClient
|
|
||||||
Repository --> DatabaseService
|
|
||||||
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Documentation
|
|
||||||
|
|
||||||
Each major subsystem is documented in its own file in this directory:
|
|
||||||
|
|
||||||
| Document | Contents |
|
|
||||||
|----------|----------|
|
|
||||||
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
|
|
||||||
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
|
|
||||||
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
|
||||||
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
|
||||||
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
|
||||||
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
|
|
||||||
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
|
||||||
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
|
||||||
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/
|
|
||||||
├── lib.rs # Tauri app setup, state initialization
|
|
||||||
├── commands/ # Tauri command handlers (90+ commands)
|
|
||||||
│ ├── mod.rs # Command exports
|
|
||||||
│ ├── player.rs # 16 player commands
|
|
||||||
│ ├── repository.rs # 27 repository commands
|
|
||||||
│ ├── playlist.rs # 7 playlist commands
|
|
||||||
│ ├── playback_mode.rs # 5 playback mode commands
|
|
||||||
│ ├── connectivity.rs # 7 connectivity commands
|
|
||||||
│ ├── storage.rs # Storage & database commands
|
|
||||||
│ ├── download.rs # 7 download commands
|
|
||||||
│ ├── offline.rs # 3 offline commands
|
|
||||||
│ └── sync.rs # Sync queue commands
|
|
||||||
├── repository/ # Repository pattern implementation
|
|
||||||
│ ├── mod.rs # MediaRepository trait, handle management
|
|
||||||
│ ├── types.rs # RepoError, Library, MediaItem, etc.
|
|
||||||
│ ├── hybrid.rs # HybridRepository with cache-first racing
|
|
||||||
│ ├── online.rs # OnlineRepository (HTTP API)
|
|
||||||
│ └── offline.rs # OfflineRepository (SQLite queries)
|
|
||||||
├── playback_mode/ # Playback mode manager
|
|
||||||
│ └── mod.rs # PlaybackMode enum, transfer logic
|
|
||||||
├── connectivity/ # Connectivity monitoring
|
|
||||||
│ └── mod.rs # ConnectivityMonitor, adaptive polling
|
|
||||||
├── jellyfin/ # Jellyfin API client
|
|
||||||
│ ├── mod.rs # Module exports
|
|
||||||
│ ├── http_client.rs # HTTP client with retry logic
|
|
||||||
│ └── client.rs # JellyfinClient for API calls
|
|
||||||
├── storage/ # Database layer
|
|
||||||
│ ├── mod.rs # Database struct, migrations
|
|
||||||
│ ├── db_service.rs # DatabaseService trait (async wrapper)
|
|
||||||
│ ├── schema.rs # Table definitions
|
|
||||||
│ └── queries/ # Query modules
|
|
||||||
├── download/ # Download manager module
|
|
||||||
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
|
|
||||||
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
|
|
||||||
│ ├── events.rs # DownloadEvent enum
|
|
||||||
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
|
|
||||||
└── player/ # Player subsystem
|
|
||||||
├── mod.rs # PlayerController
|
|
||||||
├── session.rs # MediaSessionManager, MediaSessionType
|
|
||||||
├── state.rs # PlayerState, PlayerEvent
|
|
||||||
├── media.rs # MediaItem, MediaSource, MediaType
|
|
||||||
├── queue.rs # QueueManager, RepeatMode
|
|
||||||
├── backend.rs # PlayerBackend trait, NullBackend
|
|
||||||
├── events.rs # PlayerStatusEvent, TauriEventEmitter
|
|
||||||
├── mpv/ # Linux MPV backend
|
|
||||||
│ ├── mod.rs # MpvBackend implementation
|
|
||||||
│ └── event_loop.rs # Dedicated thread for MPV operations
|
|
||||||
└── android/ # Android ExoPlayer backend
|
|
||||||
└── mod.rs # ExoPlayerBackend + JNI bindings
|
|
||||||
|
|
||||||
src/lib/
|
|
||||||
├── api/ # Thin API layer (~200 lines total)
|
|
||||||
│ ├── types.ts # TypeScript type definitions
|
|
||||||
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
|
||||||
│ ├── client.ts # JellyfinClient (helper for streaming)
|
|
||||||
│ └── sessions.ts # SessionsApi (remote session control)
|
|
||||||
├── player/ # Unified player boundary (frontend)
|
|
||||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
|
||||||
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
|
||||||
├── services/
|
|
||||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
|
||||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
|
||||||
├── stores/ # Thin reactive wrappers over Rust commands
|
|
||||||
│ ├── index.ts # Re-exports
|
|
||||||
│ ├── auth.ts # Auth store (calls Rust commands)
|
|
||||||
│ ├── player.ts # Player store
|
|
||||||
│ ├── queue.ts # Queue store
|
|
||||||
│ ├── library.ts # Library store
|
|
||||||
│ ├── playbackMode.ts # Playback mode store (~150 lines)
|
|
||||||
│ ├── connectivity.ts # Connectivity store (~250 lines)
|
|
||||||
│ └── downloads.ts # Downloads store with event listeners
|
|
||||||
└── components/
|
|
||||||
├── Search.svelte
|
|
||||||
├── player/ # Player UI components
|
|
||||||
├── playlist/ # Playlist modals (Create, AddTo)
|
|
||||||
├── sessions/ # Remote session control UI
|
|
||||||
├── downloads/ # Download UI components
|
|
||||||
└── library/ # Library UI components + PlaylistDetailView
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Architecture Changes
|
|
||||||
|
|
||||||
**What moved to Rust (~3,500 lines of business logic):**
|
|
||||||
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
|
||||||
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
|
|
||||||
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
|
|
||||||
4. **Database Service** - Async wrapper preventing UI freezing
|
|
||||||
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
|
||||||
|
|
||||||
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
|
|
||||||
- Components + routes (~14.6k lines) — UI and presentation
|
|
||||||
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
|
|
||||||
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
|
|
||||||
|
|
||||||
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
|
||||||
|
|
||||||
**Total Commands:** 90+ Tauri commands across 14 command modules
|
|
||||||
|
Before Width: | Height: | Size: 142 KiB |
@@ -1,347 +0,0 @@
|
|||||||
# Build & Release Workflow
|
|
||||||
|
|
||||||
This document explains the automated build and release process for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically:
|
|
||||||
1. ✅ Runs all tests (frontend + Rust)
|
|
||||||
2. ✅ Builds Linux binaries (AppImage + DEB)
|
|
||||||
3. ✅ Builds Android APK and AAB
|
|
||||||
4. ✅ Creates releases with artifacts
|
|
||||||
5. ✅ Tags releases with version numbers
|
|
||||||
|
|
||||||
## Workflow Triggers
|
|
||||||
|
|
||||||
### Automatic Trigger
|
|
||||||
When you push a version tag:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
1. Runs tests
|
|
||||||
2. Builds both platforms
|
|
||||||
3. Creates a GitHub release with artifacts
|
|
||||||
4. Tags it as release/prerelease based on version
|
|
||||||
|
|
||||||
### Manual Trigger
|
|
||||||
In Gitea Actions UI:
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click **Build & Release** workflow
|
|
||||||
3. Click **Run workflow**
|
|
||||||
4. Optionally specify a version
|
|
||||||
5. Workflow runs without creating a release
|
|
||||||
|
|
||||||
## Version Tagging
|
|
||||||
|
|
||||||
### Format
|
|
||||||
Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `v1.0.0` - Release version
|
|
||||||
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
|
|
||||||
- `v1.0.0-beta` - Beta version (marked as prerelease)
|
|
||||||
- `v0.1.0-alpha` - Alpha version (marked as prerelease)
|
|
||||||
|
|
||||||
### Creating a Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create and push a version tag
|
|
||||||
git tag v1.0.0 -m "Release version 1.0.0"
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# Or create from main branch
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0" main
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Status
|
|
||||||
|
|
||||||
Versions containing `rc`, `beta`, or `alpha` are marked as **prerelease**:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0-rc1 # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-beta # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-alpha # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0 # ✅ Full release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Steps
|
|
||||||
|
|
||||||
### 1. Test Phase
|
|
||||||
Runs on all tags and manual triggers:
|
|
||||||
- Frontend tests (`vitest`)
|
|
||||||
- Rust tests (`cargo test`)
|
|
||||||
- TypeScript type checking
|
|
||||||
|
|
||||||
**Failure:** Stops workflow, no build/release
|
|
||||||
|
|
||||||
### 2. Build Linux Phase
|
|
||||||
Runs after tests pass:
|
|
||||||
- Installs system dependencies
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **AppImage** - Universal Linux binary
|
|
||||||
- **DEB** - Debian/Ubuntu package
|
|
||||||
|
|
||||||
**Output:** `artifacts/linux/`
|
|
||||||
|
|
||||||
### 3. Build Android Phase
|
|
||||||
Runs in parallel with Linux build:
|
|
||||||
- Installs Android SDK/NDK
|
|
||||||
- Configures Rust for Android targets
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **APK** - Android app package (installable)
|
|
||||||
- **AAB** - Android App Bundle (for Play Store)
|
|
||||||
|
|
||||||
**Output:** `artifacts/android/`
|
|
||||||
|
|
||||||
### 4. Create Release Phase
|
|
||||||
Runs after both builds succeed (only on version tags):
|
|
||||||
- Prepares release notes
|
|
||||||
- Downloads build artifacts
|
|
||||||
- Creates GitHub/Gitea release
|
|
||||||
- Uploads all artifacts
|
|
||||||
- Tags as prerelease if applicable
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
### Linux Artifacts
|
|
||||||
|
|
||||||
#### AppImage
|
|
||||||
- **File:** `jellytau_*.AppImage`
|
|
||||||
- **Size:** ~100-150 MB
|
|
||||||
- **Use:** Run directly on any Linux distro
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
chmod +x jellytau_*.AppImage
|
|
||||||
./jellytau_*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DEB Package
|
|
||||||
- **File:** `jellytau_*.deb`
|
|
||||||
- **Size:** ~80-120 MB
|
|
||||||
- **Use:** Install on Debian/Ubuntu/similar
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i jellytau_*.deb
|
|
||||||
jellytau
|
|
||||||
```
|
|
||||||
|
|
||||||
### Android Artifacts
|
|
||||||
|
|
||||||
#### APK
|
|
||||||
- **File:** `jellytau-release.apk`
|
|
||||||
- **Size:** ~60-100 MB
|
|
||||||
- **Use:** Direct installation on Android devices
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
adb install jellytau-release.apk
|
|
||||||
# Or sideload via file manager
|
|
||||||
```
|
|
||||||
|
|
||||||
#### AAB (Android App Bundle)
|
|
||||||
- **File:** `jellytau-release.aab`
|
|
||||||
- **Size:** ~50-90 MB
|
|
||||||
- **Use:** Upload to Google Play Console
|
|
||||||
- **Note:** Cannot be installed directly; for Play Store distribution
|
|
||||||
|
|
||||||
## Release Notes
|
|
||||||
|
|
||||||
Release notes are automatically generated with:
|
|
||||||
- Version number
|
|
||||||
- Download links
|
|
||||||
- Installation instructions
|
|
||||||
- System requirements
|
|
||||||
- Known issues link
|
|
||||||
- Changelog reference
|
|
||||||
|
|
||||||
## Build Matrix
|
|
||||||
|
|
||||||
| Platform | OS | Architecture | Format |
|
|
||||||
|----------|----|----|--------|
|
|
||||||
| **Linux** | Any | x86_64 | AppImage, DEB |
|
|
||||||
| **Android** | 8.0+ | arm64, armv7, x86_64 | APK, AAB |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Build Fails During Test Phase
|
|
||||||
1. Check test output in Gitea Actions
|
|
||||||
2. Run tests locally: `bun run test` and `bun run test:rust`
|
|
||||||
3. Fix failing tests
|
|
||||||
4. Create new tag with fixed code
|
|
||||||
|
|
||||||
### Linux Build Fails
|
|
||||||
1. Check system dependencies installed
|
|
||||||
2. Verify Tauri configuration
|
|
||||||
3. Check cargo dependencies
|
|
||||||
4. Clear cache: Delete `.cargo` and `target/` directories
|
|
||||||
|
|
||||||
### Android Build Fails
|
|
||||||
1. Check Android SDK/NDK setup
|
|
||||||
2. Verify Java 17 is installed
|
|
||||||
3. Check Rust Android targets: `rustup target list`
|
|
||||||
4. Clear cache and rebuild
|
|
||||||
|
|
||||||
### Release Not Created
|
|
||||||
1. Tag must start with `v` (e.g., `v1.0.0`)
|
|
||||||
2. Tests must pass
|
|
||||||
3. Both builds must succeed
|
|
||||||
4. Check workflow logs for errors
|
|
||||||
|
|
||||||
## GitHub Release vs Gitea
|
|
||||||
|
|
||||||
The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:
|
|
||||||
|
|
||||||
1. Workflow creates artifacts
|
|
||||||
2. Artifacts are available in Actions artifacts
|
|
||||||
3. Download and manually create Gitea release, or
|
|
||||||
4. Set up Gitea API integration to auto-publish
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
### Change Release Notes Template
|
|
||||||
|
|
||||||
Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
# Add your custom release notes format here
|
|
||||||
echo "Custom notes" > release_notes.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add New Platforms
|
|
||||||
|
|
||||||
To add macOS or Windows builds:
|
|
||||||
|
|
||||||
1. Add new `build-{platform}` job
|
|
||||||
2. Set appropriate `runs-on` runner
|
|
||||||
3. Add platform-specific dependencies
|
|
||||||
4. Update artifact upload
|
|
||||||
5. Include in `needs: [build-linux, build-android, build-{platform}]`
|
|
||||||
|
|
||||||
### Change Build Targets
|
|
||||||
|
|
||||||
Modify Tauri configuration or add targets:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Build for Linux
|
|
||||||
run: |
|
|
||||||
# Add target specification
|
|
||||||
bun run tauri build -- --target x86_64-unknown-linux-gnu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Check Status
|
|
||||||
1. Go to **Actions** tab in Gitea
|
|
||||||
2. View **Build & Release** workflow runs
|
|
||||||
3. Click specific run to see logs
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
Set up notifications for:
|
|
||||||
- Build failures
|
|
||||||
- Release creation
|
|
||||||
- Tag pushes
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Build Times (Approximate)
|
|
||||||
- Test phase: 5-10 minutes
|
|
||||||
- Linux build: 10-15 minutes
|
|
||||||
- Android build: 15-20 minutes
|
|
||||||
- Total: 30-45 minutes
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
Workflow caches:
|
|
||||||
- Rust dependencies (cargo)
|
|
||||||
- Bun node_modules
|
|
||||||
- Android SDK components
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
The workflow uses:
|
|
||||||
- `GITHUB_TOKEN` - Built-in, no setup needed
|
|
||||||
- No credentials needed for Gitea
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
To verify build integrity:
|
|
||||||
1. Download artifacts
|
|
||||||
2. Verify signatures (if implemented)
|
|
||||||
3. Check file hashes
|
|
||||||
4. Test on target platform
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
2. Tag releases in git
|
|
||||||
3. Update CHANGELOG.md before tagging
|
|
||||||
4. Include release notes in tag message
|
|
||||||
|
|
||||||
### Testing Before Release
|
|
||||||
```bash
|
|
||||||
# Local testing before release
|
|
||||||
bun run test # Frontend tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run check # Type checking
|
|
||||||
bun run tauri build # Local build test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
|
||||||
2. Update [README.md](../README.md) with new features
|
|
||||||
3. Document breaking changes
|
|
||||||
4. Add migration guide if needed
|
|
||||||
|
|
||||||
## Example Release Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
|
|
||||||
vim package.json
|
|
||||||
vim src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
# 2. Update CHANGELOG
|
|
||||||
vim CHANGELOG.md
|
|
||||||
|
|
||||||
# 3. Commit changes
|
|
||||||
git add .
|
|
||||||
git commit -m "Bump version to v1.0.0"
|
|
||||||
|
|
||||||
# 4. Create annotated tag
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Feature 1
|
|
||||||
- Feature 2
|
|
||||||
|
|
||||||
Fixes:
|
|
||||||
- Fix 1
|
|
||||||
- Fix 2"
|
|
||||||
|
|
||||||
# 5. Push tag to trigger workflow
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# 6. Monitor workflow in Gitea Actions
|
|
||||||
# Wait for tests → Linux build → Android build → Release
|
|
||||||
|
|
||||||
# 7. Download artifacts and test
|
|
||||||
# Visit release page and verify downloads
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Tauri Documentation](https://tauri.app/)
|
|
||||||
- [Semantic Versioning](https://semver.org/)
|
|
||||||
- [GitHub Release Best Practices](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
|
|
||||||
- [Android App Bundle](https://developer.android.com/guide/app-bundle)
|
|
||||||
- [AppImage Documentation](https://docs.appimage.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# Building and Pushing the JellyTau Builder Image
|
|
||||||
|
|
||||||
This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker installed and running
|
|
||||||
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
|
|
||||||
- Docker registry credentials configured (`docker login`)
|
|
||||||
|
|
||||||
## Building the Builder Image
|
|
||||||
|
|
||||||
### Step 1: Build the Image Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the project root
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a local image with:
|
|
||||||
- All system dependencies
|
|
||||||
- Rust with Android targets
|
|
||||||
- Android SDK and NDK
|
|
||||||
- Node.js and Bun
|
|
||||||
- All build tools pre-installed
|
|
||||||
|
|
||||||
### Step 2: Tag for Your Registry
|
|
||||||
|
|
||||||
Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Login to Your Registry
|
|
||||||
|
|
||||||
If not already logged in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login gitea.tourolle.paris
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Push to Registry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete One-Liner
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verifying the Build
|
|
||||||
|
|
||||||
Check that the image was pushed successfully:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List images in your registry (depends on registry API support)
|
|
||||||
docker search gitea.tourolle.paris/dtourolle/jellytau-builder
|
|
||||||
|
|
||||||
# Or pull and test locally
|
|
||||||
docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using in CI/CD
|
|
||||||
|
|
||||||
The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.
|
|
||||||
|
|
||||||
## Updating the Builder Image
|
|
||||||
|
|
||||||
When dependencies change (new Rust version, Android SDK update, etc.):
|
|
||||||
|
|
||||||
1. Update `Dockerfile.builder` with the new configuration
|
|
||||||
2. Rebuild and push with a new tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
|
|
||||||
docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update the workflow to use the new tag:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Image Contents
|
|
||||||
|
|
||||||
The builder image includes:
|
|
||||||
|
|
||||||
- **Base OS**: Ubuntu 24.04
|
|
||||||
- **Languages**:
|
|
||||||
- Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
|
|
||||||
- Node.js 20.x
|
|
||||||
- OpenJDK 17 (for Android)
|
|
||||||
- **Tools**:
|
|
||||||
- Bun package manager
|
|
||||||
- Android SDK 34
|
|
||||||
- Android NDK 27.0.11902837
|
|
||||||
- Build essentials (gcc, make, etc.)
|
|
||||||
- Git, curl, wget
|
|
||||||
- libssl, libclang development libraries
|
|
||||||
- **Pre-configured**:
|
|
||||||
- Rust toolchain components (rustfmt, clippy)
|
|
||||||
- Android SDK/NDK environment variables
|
|
||||||
- All paths optimized for building
|
|
||||||
|
|
||||||
## Build Time
|
|
||||||
|
|
||||||
First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK).
|
|
||||||
Subsequent builds are cached and take seconds.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
The built image is approximately **4-5 GB**. Ensure your registry has sufficient storage.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Image not found" in CI
|
|
||||||
- Verify the image name matches exactly in the workflow
|
|
||||||
- Check that the image was successfully pushed: `docker push` output should show successful layers
|
|
||||||
- Ensure Gitea has access to your registry (check network/firewall)
|
|
||||||
|
|
||||||
### Build fails with "command not found"
|
|
||||||
- The image may not have finished pushing. Wait a few moments and retry the CI job.
|
|
||||||
- Check that all layers were pushed successfully in the push output.
|
|
||||||
|
|
||||||
### Registry authentication in CI
|
|
||||||
If your registry requires credentials in CI:
|
|
||||||
1. Create a deploy token in your registry
|
|
||||||
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
|
|
||||||
3. Use in workflow:
|
|
||||||
```yaml
|
|
||||||
- name: Login to Registry
|
|
||||||
run: |
|
|
||||||
docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Docker Build Documentation](https://docs.docker.com/build/)
|
|
||||||
- [Docker Push Documentation](https://docs.docker.com/engine/reference/commandline/push/)
|
|
||||||
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
# Docker & CI/CD Setup for JellyTau
|
|
||||||
|
|
||||||
This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The setup includes:
|
|
||||||
- **Dockerfile.builder**: Pre-built image with all dependencies (push to your registry)
|
|
||||||
- **Dockerfile**: Multi-stage build for local testing and building
|
|
||||||
- **docker-compose.yml**: Orchestration for local development and testing
|
|
||||||
- **.gitea/workflows/build-and-test.yml**: Automated CI/CD pipeline using pre-built builder image
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
**For CI/CD (Gitea Actions)**:
|
|
||||||
1. Build and push builder image (see [build-builder-image.md](build-builder-image.md))
|
|
||||||
2. Push to master branch - workflow runs automatically
|
|
||||||
3. Check Actions tab for results and APK artifacts
|
|
||||||
|
|
||||||
**For Local Testing**:
|
|
||||||
```bash
|
|
||||||
docker-compose run test # Run tests
|
|
||||||
docker-compose run android-build # Build APK
|
|
||||||
docker-compose run dev # Interactive shell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Usage
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Docker Engine 20.10+
|
|
||||||
- Docker Compose 2.0+ (if using docker-compose)
|
|
||||||
- At least 10GB free disk space (for Android SDK and build artifacts)
|
|
||||||
|
|
||||||
### Building the Docker Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the complete image
|
|
||||||
docker build -t jellytau:latest .
|
|
||||||
|
|
||||||
# Build specific target
|
|
||||||
docker build -t jellytau:test --target test .
|
|
||||||
docker build -t jellytau:android --target android-build .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Docker Compose
|
|
||||||
|
|
||||||
#### Run Tests Only
|
|
||||||
```bash
|
|
||||||
docker-compose run test
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Install all dependencies
|
|
||||||
2. Run frontend tests (Vitest)
|
|
||||||
3. Run Rust backend tests
|
|
||||||
4. Report results
|
|
||||||
|
|
||||||
#### Build Android APK
|
|
||||||
```bash
|
|
||||||
docker-compose run android-build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Run tests first (depends on test service)
|
|
||||||
2. If tests pass, build the Android APK
|
|
||||||
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`
|
|
||||||
|
|
||||||
#### Interactive Development
|
|
||||||
```bash
|
|
||||||
docker-compose run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts an interactive shell with all development tools available. From here you can:
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
bun test
|
|
||||||
bun run tauri android build --apk true
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run All Services in Sequence
|
|
||||||
```bash
|
|
||||||
docker-compose up --abort-on-container-exit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extracting Build Artifacts
|
|
||||||
|
|
||||||
After a successful build, APK files are located in:
|
|
||||||
```
|
|
||||||
src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy to your host machine:
|
|
||||||
```bash
|
|
||||||
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gitea Act CI/CD Pipeline
|
|
||||||
|
|
||||||
The `.gitea/workflows/build-and-test.yml` workflow automates:
|
|
||||||
|
|
||||||
**Single Job**: Runs on every push to `master` and PRs
|
|
||||||
- Uses pre-built builder image (no setup time)
|
|
||||||
- Installs project dependencies
|
|
||||||
- Runs frontend tests (Vitest)
|
|
||||||
- Runs Rust backend tests
|
|
||||||
- Builds the frontend
|
|
||||||
- Builds the Android APK
|
|
||||||
- Uploads APK as artifact (30-day retention)
|
|
||||||
|
|
||||||
The workflow skips markdown files to avoid unnecessary builds.
|
|
||||||
|
|
||||||
### Workflow Triggers
|
|
||||||
|
|
||||||
The workflow runs on:
|
|
||||||
- Push to `master` or `main` branches
|
|
||||||
- Pull requests to `master` or `main` branches
|
|
||||||
- Can be extended with: `workflow_dispatch` for manual triggers
|
|
||||||
|
|
||||||
### Setting Up the Builder Image
|
|
||||||
|
|
||||||
Before using the CI/CD pipeline, you must build and push the builder image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
|
|
||||||
# Tag for your registry
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
# Push to registry
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
See [build-builder-image.md](build-builder-image.md) for detailed instructions.
|
|
||||||
|
|
||||||
### Setting Up Gitea Act
|
|
||||||
|
|
||||||
1. **Ensure builder image is pushed** (see above)
|
|
||||||
|
|
||||||
2. **Push to Gitea repository**:
|
|
||||||
The workflow will automatically trigger on push to `master` or pull requests
|
|
||||||
|
|
||||||
3. **View workflow runs in Gitea UI**:
|
|
||||||
- Navigate to your repository
|
|
||||||
- Go to Actions tab
|
|
||||||
- Click on workflow runs to see logs
|
|
||||||
|
|
||||||
4. **Test locally** (optional):
|
|
||||||
```bash
|
|
||||||
# Install act if needed
|
|
||||||
curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
|
|
||||||
|
|
||||||
# Run locally (requires builder image to be available)
|
|
||||||
./act push --file .gitea/workflows/build-and-test.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customizing the Workflow
|
|
||||||
|
|
||||||
#### Modify Build Triggers
|
|
||||||
Edit `.gitea/workflows/build-and-test.yml` to change when builds run:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop # Add more branches
|
|
||||||
paths:
|
|
||||||
- 'src/**' # Only run if src/ changes
|
|
||||||
- 'src-tauri/**' # Only run if Rust code changes
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Add Notifications
|
|
||||||
Add Slack, Discord, or email notifications on build completion:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Notify on success
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
curl -X POST https://slack-webhook-url...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Customize APK Upload
|
|
||||||
Modify artifact retention or add to cloud storage:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload APK to S3
|
|
||||||
uses: actions/s3-sync@v1
|
|
||||||
with:
|
|
||||||
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
|
|
||||||
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
|
|
||||||
aws_bucket: my-apk-bucket
|
|
||||||
source_dir: src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Setup in CI
|
|
||||||
|
|
||||||
### Secret Variables
|
|
||||||
To use secrets in the workflow, set them in Gitea:
|
|
||||||
|
|
||||||
1. Go to Repository Settings → Secrets
|
|
||||||
2. Add secrets like:
|
|
||||||
- `AWS_ACCESS_KEY` for S3 uploads
|
|
||||||
- `SLACK_WEBHOOK_URL` for notifications
|
|
||||||
- `GITHUB_TOKEN` for releases (pre-configured)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Out of Memory During Build
|
|
||||||
Android builds are memory-intensive. If you get OOM errors:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Limit memory in docker-compose
|
|
||||||
services:
|
|
||||||
android-build:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 6G
|
|
||||||
```
|
|
||||||
|
|
||||||
Or increase Docker's memory allocation in Docker Desktop settings.
|
|
||||||
|
|
||||||
### Android SDK Download Timeout
|
|
||||||
If downloads timeout, increase timeout or download manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In container, with longer timeout
|
|
||||||
timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Compilation Errors
|
|
||||||
Make sure Rust is updated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rustup update
|
|
||||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache Issues
|
|
||||||
Clear Docker cache and rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose down -v # Remove volumes
|
|
||||||
docker system prune # Clean up dangling images
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
1. **Cache Reuse**: Both Docker and Gitea Act cache dependencies across runs
|
|
||||||
2. **Parallel Steps**: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
|
|
||||||
3. **Incremental Builds**: Rust and Node caches persist between runs
|
|
||||||
4. **Docker Buildkit**: Enable for faster builds:
|
|
||||||
```bash
|
|
||||||
DOCKER_BUILDKIT=1 docker build .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
|
|
||||||
- NDK is downloaded from official Google servers (verified via HTTPS)
|
|
||||||
- No credentials are stored in the Dockerfile
|
|
||||||
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
|
|
||||||
- Lock dependency versions in `Cargo.toml` and `package.json`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Test locally with `docker-compose up`
|
|
||||||
2. Push to your Gitea repository
|
|
||||||
3. Monitor workflow runs in the Actions tab
|
|
||||||
4. Configure secrets in repository settings for production builds
|
|
||||||
5. Set up artifact retention policies (currently 30 days)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
|
||||||
- [Android Build Tools](https://developer.android.com/studio/command-line)
|
|
||||||
- [Tauri Android Guide](https://tauri.app/v1/guides/building/android)
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
# Release Checklist
|
|
||||||
|
|
||||||
Quick reference for creating a JellyTau release.
|
|
||||||
|
|
||||||
## Pre-Release (1-2 days before)
|
|
||||||
|
|
||||||
- [ ] Code is on `master`/`main` branch
|
|
||||||
- [ ] All feature branches are merged and tested
|
|
||||||
- [ ] No failing tests locally: `bun run test` and `bun run test:rust`
|
|
||||||
- [ ] Requirement traceability check passes: `bun run traces:json`
|
|
||||||
- [ ] Type checking passes: `bun run check`
|
|
||||||
|
|
||||||
## Update Version (Day before)
|
|
||||||
|
|
||||||
- [ ] Decide on version number (semantic versioning)
|
|
||||||
- Example: `v1.2.0` (major.minor.patch)
|
|
||||||
- Example: `v1.0.0-rc1` (release candidate)
|
|
||||||
- Example: `v1.0.0-beta` (beta)
|
|
||||||
|
|
||||||
- [ ] Update version in files:
|
|
||||||
```bash
|
|
||||||
# Check these files for version numbers
|
|
||||||
cat package.json | grep version
|
|
||||||
cat src-tauri/tauri.conf.json | grep version
|
|
||||||
cat src-tauri/Cargo.toml | grep version
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Update `CHANGELOG.md`:
|
|
||||||
- [ ] Add section for new version
|
|
||||||
- [ ] List all features added
|
|
||||||
- [ ] List all bugs fixed
|
|
||||||
- [ ] List breaking changes (if any)
|
|
||||||
- [ ] Add upgrade instructions (if needed)
|
|
||||||
- [ ] Format: Markdown with clear sections
|
|
||||||
|
|
||||||
- [ ] Update `README.md`:
|
|
||||||
- [ ] Update any version references
|
|
||||||
- [ ] Update feature list if applicable
|
|
||||||
- [ ] Update requirements if changed
|
|
||||||
|
|
||||||
- [ ] Commit changes:
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Bump version to v1.2.0"
|
|
||||||
git push origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final Check Before Release
|
|
||||||
|
|
||||||
- [ ] Run full test suite:
|
|
||||||
```bash
|
|
||||||
bun run test # Frontend tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run check # Type checking
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Build locally (optional but recommended):
|
|
||||||
```bash
|
|
||||||
# Test Linux build
|
|
||||||
bun run tauri build
|
|
||||||
|
|
||||||
# Test Android build
|
|
||||||
bun run tauri android build
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] No uncommitted changes:
|
|
||||||
```bash
|
|
||||||
git status # Should show clean working directory
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release (Tag & Push)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create annotated tag with release notes
|
|
||||||
git tag -a v1.2.0 -m "Release version 1.2.0
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- New feature 1
|
|
||||||
- New feature 2
|
|
||||||
|
|
||||||
Fixes:
|
|
||||||
- Fixed bug 1
|
|
||||||
- Fixed bug 2
|
|
||||||
|
|
||||||
Improvements:
|
|
||||||
- Performance improvement 1
|
|
||||||
- UI improvement 1
|
|
||||||
|
|
||||||
Breaking Changes:
|
|
||||||
- None (or list if applicable)
|
|
||||||
|
|
||||||
Migration:
|
|
||||||
- No action required (or include steps if applicable)"
|
|
||||||
|
|
||||||
# 2. Push tag to trigger workflow
|
|
||||||
git push origin v1.2.0
|
|
||||||
|
|
||||||
# 3. Monitor in Gitea Actions
|
|
||||||
# Go to Actions tab and watch the workflow run
|
|
||||||
```
|
|
||||||
|
|
||||||
## During Release (While Workflow Runs)
|
|
||||||
|
|
||||||
- [ ] Watch workflow progress in Gitea Actions
|
|
||||||
- [ ] Monitor for test failures
|
|
||||||
- [ ] Monitor for build failures
|
|
||||||
- [ ] Check build logs if any step fails
|
|
||||||
|
|
||||||
## After Release (Workflow Complete)
|
|
||||||
|
|
||||||
- [ ] Download artifacts from release page:
|
|
||||||
- [ ] `jellytau_*.AppImage` (Linux)
|
|
||||||
- [ ] `jellytau_*.deb` (Linux)
|
|
||||||
- [ ] `jellytau-release.apk` (Android)
|
|
||||||
- [ ] `jellytau-release.aab` (Android)
|
|
||||||
|
|
||||||
- [ ] Basic testing of artifacts:
|
|
||||||
- [ ] Linux AppImage runs
|
|
||||||
- [ ] Linux DEB installs and runs
|
|
||||||
- [ ] Android APK installs (via `adb` or sideload)
|
|
||||||
|
|
||||||
- [ ] Verify release page:
|
|
||||||
- [ ] Title is correct: "JellyTau vX.Y.Z"
|
|
||||||
- [ ] Release notes are formatted correctly
|
|
||||||
- [ ] All artifacts are uploaded
|
|
||||||
- [ ] Release type is correct (prerelease vs release)
|
|
||||||
|
|
||||||
- [ ] Announce release:
|
|
||||||
- [ ] Post to relevant channels/communities
|
|
||||||
- [ ] Update website/docs
|
|
||||||
- [ ] Tag contributors if applicable
|
|
||||||
|
|
||||||
## Rollback (If Issues Found)
|
|
||||||
|
|
||||||
If critical issues are found after release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1: Delete tag locally and remotely
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin :refs/tags/v1.2.0
|
|
||||||
|
|
||||||
# Option 2: Mark as prerelease in release page
|
|
||||||
# Then plan immediate patch release (v1.2.1)
|
|
||||||
|
|
||||||
# Option 3: Create hotfix branch and release v1.2.1
|
|
||||||
git checkout -b hotfix/v1.2.1
|
|
||||||
# Fix issues
|
|
||||||
git commit -m "Fix critical issue"
|
|
||||||
git tag v1.2.1
|
|
||||||
git push origin hotfix/v1.2.1 v1.2.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Examples
|
|
||||||
|
|
||||||
### Major Release
|
|
||||||
```
|
|
||||||
v2.0.0 - Major version bump
|
|
||||||
- Significant new features
|
|
||||||
- Breaking API changes
|
|
||||||
- Major UI redesign
|
|
||||||
```
|
|
||||||
|
|
||||||
### Minor Release
|
|
||||||
```
|
|
||||||
v1.2.0 - Feature release
|
|
||||||
- New features
|
|
||||||
- Backward compatible
|
|
||||||
- Bug fixes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Patch Release
|
|
||||||
```
|
|
||||||
v1.1.1 - Bug fix/patch
|
|
||||||
- Bug fixes only
|
|
||||||
- No new features
|
|
||||||
- Backward compatible
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pre-releases
|
|
||||||
```
|
|
||||||
v1.2.0-alpha - Early development
|
|
||||||
v1.2.0-beta - Late development, feature complete
|
|
||||||
v1.2.0-rc1 - Release candidate, minimal fixes only
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
Key files for versioning:
|
|
||||||
- `package.json` - Frontend version
|
|
||||||
- `src-tauri/tauri.conf.json` - Tauri config version
|
|
||||||
- `src-tauri/Cargo.toml` - Rust version
|
|
||||||
- `CHANGELOG.md` - Release history
|
|
||||||
- `README.md` - Project documentation
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Tests Fail Before Release
|
|
||||||
1. Don't push tag yet
|
|
||||||
2. Fix failing tests locally
|
|
||||||
3. Push fixes to master
|
|
||||||
4. Re-run test suite
|
|
||||||
5. Then tag and push
|
|
||||||
|
|
||||||
### Build Fails in CI
|
|
||||||
1. Check detailed logs in Gitea Actions
|
|
||||||
2. Fix issue locally
|
|
||||||
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
|
|
||||||
4. Push fix to master
|
|
||||||
5. Create new tag with fix
|
|
||||||
|
|
||||||
### Release Already Exists
|
|
||||||
1. If workflow runs twice, artifacts may conflict
|
|
||||||
2. Check release page
|
|
||||||
3. If duplicates exist, delete and re-release
|
|
||||||
|
|
||||||
### Artifacts Missing
|
|
||||||
1. Check build logs for errors
|
|
||||||
2. Verify platform-specific dependencies
|
|
||||||
3. Delete tag and retry after fixes
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
- Tests: ~5-10 minutes
|
|
||||||
- Linux build: ~10-15 minutes
|
|
||||||
- Android build: ~15-20 minutes
|
|
||||||
- Total release time: ~30-45 minutes
|
|
||||||
|
|
||||||
First build takes longer (cache warming). Subsequent releases are faster due to caching.
|
|
||||||
|
|
||||||
## Template: Release Notes
|
|
||||||
|
|
||||||
```
|
|
||||||
## 🎉 JellyTau vX.Y.Z
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- New feature 1
|
|
||||||
- New feature 2
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
- Fixed issue #123
|
|
||||||
- Fixed issue #456
|
|
||||||
|
|
||||||
### 🚀 Performance
|
|
||||||
- Improvement 1
|
|
||||||
- Improvement 2
|
|
||||||
|
|
||||||
### 📱 Downloads
|
|
||||||
- [Linux AppImage](#) - Run on any Linux
|
|
||||||
- [Linux DEB](#) - Install on Ubuntu/Debian
|
|
||||||
- [Android APK](#) - Install on Android devices
|
|
||||||
- [Android AAB](#) - For Google Play Store
|
|
||||||
|
|
||||||
### 📋 Requirements
|
|
||||||
**Linux:** 64-bit, GLIBC 2.29+
|
|
||||||
**Android:** 8.0+
|
|
||||||
|
|
||||||
### 🔗 Links
|
|
||||||
- [Changelog](../../CHANGELOG.md)
|
|
||||||
- [Issues](../../issues)
|
|
||||||
- [Discussion](../../discussions)
|
|
||||||
|
|
||||||
---
|
|
||||||
Built with Tauri, SvelteKit, and Rust 🦀
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View existing tags
|
|
||||||
git tag -l
|
|
||||||
|
|
||||||
# Create release locally (dry run)
|
|
||||||
git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
|
|
||||||
|
|
||||||
# List commits since last tag
|
|
||||||
git log v1.1.0..HEAD --oneline
|
|
||||||
|
|
||||||
# Show tag details
|
|
||||||
git show v1.2.0
|
|
||||||
|
|
||||||
# Rename tag (if needed)
|
|
||||||
git tag v1.2.0_old v1.2.0
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin v1.2.0_old v1.2.0
|
|
||||||
|
|
||||||
# Delete tag locally and remotely
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin :refs/tags/v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Tips:**
|
|
||||||
- ✅ Always test locally before release
|
|
||||||
- ✅ Use semantic versioning consistently
|
|
||||||
- ✅ Document changes in CHANGELOG
|
|
||||||
- ✅ Wait for full workflow completion
|
|
||||||
- ✅ Test release artifacts before announcing
|
|
||||||
|
|
||||||
**Remember:** A good release is a tested release! 🚀
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
# Requirements Specification
|
|
||||||
|
|
||||||
This document captures JellyTau's user requirements, software requirements,
|
|
||||||
traceability matrix, test traceability, and known technical debt.
|
|
||||||
|
|
||||||
For a narrative overview of the system design, see
|
|
||||||
[docs/architecture/](architecture/). For development workflows, see the
|
|
||||||
[README](../README.md) and [scripts/README.md](../scripts/README.md).
|
|
||||||
|
|
||||||
## 1. User Requirements
|
|
||||||
|
|
||||||
| ID | Requirement | Priority | Status |
|
|
||||||
|----|-------------|----------|--------|
|
|
||||||
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
|
||||||
| UR-002 | Access media when online or offline | High | Done |
|
|
||||||
| UR-003 | Play videos | High | Done |
|
|
||||||
| UR-004 | Play audio uninterrupted | High | Done |
|
|
||||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
|
||||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
|
||||||
| UR-007 | Navigate media in library | High | Done |
|
|
||||||
| UR-008 | Search media across libraries | High | Done |
|
|
||||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
|
||||||
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
|
||||||
| UR-011 | Download media on demand | Medium | Done |
|
|
||||||
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
|
||||||
| UR-013 | View and manage downloaded media | Medium | Done |
|
|
||||||
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
|
|
||||||
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
|
||||||
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
|
||||||
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
|
||||||
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
|
||||||
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
|
||||||
| UR-020 | Select subtitles for video content | High | Done |
|
|
||||||
| UR-021 | Select audio track for video content | High | Done |
|
|
||||||
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
|
||||||
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
|
||||||
| UR-024 | View recently added content on server | Medium | Done |
|
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
|
||||||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
|
||||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
|
||||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
|
||||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
|
||||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
|
||||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
|
||||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
|
||||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
|
||||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
|
||||||
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
|
||||||
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
|
||||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
|
||||||
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
|
||||||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Software Requirements
|
|
||||||
|
|
||||||
### 2.1 Integration Requirements
|
|
||||||
|
|
||||||
External system integrations and platform-specific implementations.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
|
||||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
|
||||||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
|
||||||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
|
||||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
|
||||||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
|
||||||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
|
||||||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
|
||||||
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
|
||||||
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
|
||||||
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
|
||||||
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
|
||||||
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
|
||||||
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
|
||||||
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
|
||||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
|
||||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
|
||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
|
||||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
|
||||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
|
||||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
|
||||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
|
||||||
|
|
||||||
API endpoints and data contracts required for Jellyfin integration.
|
|
||||||
|
|
||||||
| ID | Requirement | Endpoint Category | Traces To | Status |
|
|
||||||
|----|-------------|-------------------|-----------|--------|
|
|
||||||
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
|
||||||
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
|
||||||
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
|
||||||
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
|
||||||
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
|
||||||
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
|
||||||
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
|
||||||
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
|
||||||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
|
||||||
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
|
||||||
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
|
||||||
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
|
||||||
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
|
||||||
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
|
||||||
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
|
||||||
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
|
||||||
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
|
||||||
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
|
||||||
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
|
|
||||||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
|
|
||||||
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
|
||||||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
|
||||||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
|
||||||
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
|
||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
|
||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
|
||||||
|
|
||||||
Internal architecture, components, and application logic.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
|
||||||
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
|
||||||
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
|
||||||
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
|
||||||
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
|
||||||
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
|
||||||
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
|
||||||
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
|
||||||
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
|
||||||
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
|
||||||
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
|
||||||
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
|
|
||||||
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
|
||||||
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
|
||||||
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
|
||||||
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
|
||||||
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
|
|
||||||
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
|
||||||
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
|
||||||
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
|
||||||
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
|
||||||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
|
||||||
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
|
||||||
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
|
||||||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
|
||||||
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
|
||||||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
|
||||||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
|
||||||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
|
||||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
|
||||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
|
||||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
|
||||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
|
||||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
|
||||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
|
||||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
|
||||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
|
||||||
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
|
||||||
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
|
||||||
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
|
||||||
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
|
||||||
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
|
||||||
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
|
||||||
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
|
||||||
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
|
||||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
|
||||||
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
|
||||||
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Traceability Matrix
|
|
||||||
|
|
||||||
### User Requirements to Software Requirements
|
|
||||||
|
|
||||||
| User Req | Integration Requirements | Development Requirements |
|
|
||||||
|----------|-------------------------|-------------------------|
|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
|
||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
|
||||||
| UR-010 | IR-012, IR-021 | DR-037 |
|
|
||||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
|
||||||
| UR-012 | IR-009, IR-014 | - |
|
|
||||||
| UR-013 | IR-013 | DR-017 |
|
|
||||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
|
||||||
| UR-015 | - | DR-005, DR-020 |
|
|
||||||
| UR-016 | - | - |
|
|
||||||
| UR-017 | - | DR-014, DR-021 |
|
|
||||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
|
||||||
| UR-019 | IR-015 | DR-022 |
|
|
||||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
|
||||||
| UR-022 | IR-017 | DR-025 |
|
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
|
||||||
| UR-024 | IR-010 | DR-027 |
|
|
||||||
| UR-025 | IR-015 | DR-028 |
|
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
|
||||||
| UR-027 | IR-020 | DR-030 |
|
|
||||||
| UR-028 | - | DR-031 |
|
|
||||||
| UR-029 | - | DR-032 |
|
|
||||||
| UR-030 | IR-010 | DR-033 |
|
|
||||||
| UR-031 | - | DR-034 |
|
|
||||||
| UR-032 | - | DR-035 |
|
|
||||||
| UR-033 | - | DR-036 |
|
|
||||||
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
|
||||||
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
|
||||||
| UR-036 | IR-022, IR-023 | DR-041 |
|
|
||||||
| UR-037 | IR-010 | DR-042 |
|
|
||||||
| UR-038 | IR-010 | DR-043 |
|
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Test Traceability
|
|
||||||
|
|
||||||
### Unit Tests to Software Requirements
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| UT-001 | Player state transitions | DR-001 | Pending |
|
|
||||||
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
|
||||||
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
|
||||||
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
|
||||||
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
|
||||||
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
|
||||||
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
|
||||||
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
|
||||||
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
|
||||||
| UT-010 | Download queue management | DR-015 | Done |
|
|
||||||
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
|
||||||
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
|
||||||
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
|
||||||
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
|
||||||
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
|
||||||
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
|
||||||
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
|
||||||
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
|
||||||
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
|
||||||
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
|
||||||
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
|
||||||
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
|
||||||
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
|
||||||
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
|
||||||
| UT-027 | NullBackend set volume | DR-004 | Done |
|
|
||||||
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
|
||||||
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
|
||||||
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
|
||||||
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
|
||||||
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
|
||||||
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
|
||||||
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
|
||||||
| UT-035 | Download event completed serialization | DR-015 | Done |
|
|
||||||
| UT-036 | Download event failed serialization | DR-015 | Done |
|
|
||||||
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
|
||||||
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
|
||||||
| UT-039 | Download manager creation | DR-015 | Done |
|
|
||||||
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
|
||||||
| UT-041 | Download info serialization | DR-015 | Done |
|
|
||||||
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
|
||||||
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
|
||||||
| UT-044 | Offline item serialization | DR-017 | Done |
|
|
||||||
| UT-045 | Smart cache default config | DR-015 | Done |
|
|
||||||
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
|
||||||
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
|
||||||
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
|
||||||
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
|
||||||
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
|
||||||
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
|
||||||
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
|
||||||
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
|
||||||
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
|
||||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
|
||||||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
|
||||||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
|
||||||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
|
||||||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
|
||||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
|
||||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Technical Debt
|
|
||||||
|
|
||||||
### Linux Keyring Integration Workaround
|
|
||||||
|
|
||||||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
|
||||||
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
|
||||||
- Session restoration fails on app restart even though credentials exist
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
|
||||||
|
|
||||||
**Current Workaround**:
|
|
||||||
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
|
||||||
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
|
||||||
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
|
||||||
- **Delete**: `secret-tool clear service <service> username <username>`
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
|
|
||||||
Linux-specific `secret-tool` save/get/delete paths.
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
- Monitor `keyring-rs` for bug fixes in future versions
|
|
||||||
- Consider alternative secure storage libraries
|
|
||||||
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
|
||||||
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - The workaround is functionally equivalent to proper keyring integration
|
|
||||||
- Credentials are stored securely in the system keyring
|
|
||||||
- Session restoration works correctly
|
|
||||||
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
|
||||||
- Already available on most Linux distributions by default
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Platform Playback Backend Parity (Linux vs Android)
|
|
||||||
|
|
||||||
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
|
||||||
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
|
||||||
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
|
||||||
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
|
|
||||||
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
|
||||||
|
|
||||||
**Feature Parity Matrix**:
|
|
||||||
|
|
||||||
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
|
||||||
|---------|-------------|---------------------|--------|
|
|
||||||
| Basic playback | ✅ | ✅ | Parity |
|
|
||||||
| Volume control | ✅ | ✅ | Parity |
|
|
||||||
| Seek | ✅ | ✅ | Parity |
|
|
||||||
| Crossfade | ✅ | ❌ | Gap |
|
|
||||||
| Gapless playback | ✅ | ❌ | Gap |
|
|
||||||
| Volume normalization | ✅ | ❌ | Gap |
|
|
||||||
| Position updates | 250ms | On-demand | Inconsistent |
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
|
||||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
|
||||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
|
||||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
|
||||||
5. Standardize position update frequency across platforms
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
|
||||||
- User experience differs between platforms
|
|
||||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
|
||||||
|
|
||||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend Playback Code Duplication
|
|
||||||
|
|
||||||
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
|
||||||
- Same `$derived` state merging logic for local/remote playback in both components
|
|
||||||
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
|
||||||
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
|
||||||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
|
||||||
|
|
||||||
**Duplicated Code**:
|
|
||||||
```typescript
|
|
||||||
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
|
||||||
handlePlayPause(), handleNext(), handlePrevious(),
|
|
||||||
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
|
||||||
|
|
||||||
// These derived states use identical logic:
|
|
||||||
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Create `src/lib/utils/playbackUnits.ts`:
|
|
||||||
```typescript
|
|
||||||
export const TICKS_PER_SECOND = 10_000_000;
|
|
||||||
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
|
||||||
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
|
||||||
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
|
||||||
- Single source of truth for merged local/remote state
|
|
||||||
|
|
||||||
3. Simplify handler wrappers using a utility:
|
|
||||||
```typescript
|
|
||||||
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
|
||||||
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - Code works correctly but violates DRY principle
|
|
||||||
- Maintenance burden when logic needs to change
|
|
||||||
- Risk of handlers diverging over time
|
|
||||||
|
|
||||||
**Traces To**: DR-009
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
# Requirement Traceability CI/CD Pipeline
|
|
||||||
|
|
||||||
This document explains the automated requirement traceability validation system for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:
|
|
||||||
- ✅ Requirements are implemented with clear traceability
|
|
||||||
- ✅ No requirement coverage regressions
|
|
||||||
- ✅ Code changes are linked to specific requirements
|
|
||||||
- ✅ Quality metrics are tracked over time
|
|
||||||
|
|
||||||
## Gitea Actions Workflows
|
|
||||||
|
|
||||||
Two workflows are configured in `.gitea/workflows/`:
|
|
||||||
|
|
||||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
|
||||||
Gitea-native workflow with:
|
|
||||||
- ✅ Automatic trace extraction
|
|
||||||
- ✅ Coverage validation against minimum threshold (50%)
|
|
||||||
- ✅ Modified file checking
|
|
||||||
- ✅ Artifact preservation
|
|
||||||
- ✅ Summary reports
|
|
||||||
|
|
||||||
**Runs on:** Every push and pull request
|
|
||||||
|
|
||||||
### 2. `traceability.yml` (Alternative)
|
|
||||||
GitHub-compatible workflow with additional features:
|
|
||||||
- Pull request comments with coverage stats
|
|
||||||
- GitHub-specific integrations
|
|
||||||
|
|
||||||
## What Gets Validated
|
|
||||||
|
|
||||||
### 1. Trace Extraction
|
|
||||||
```bash
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
```
|
|
||||||
Extracts all TRACES comments from:
|
|
||||||
- TypeScript files (`src/**/*.ts`)
|
|
||||||
- Svelte components (`src/**/*.svelte`)
|
|
||||||
- Rust code (`src-tauri/src/**/*.rs`)
|
|
||||||
- Test files
|
|
||||||
|
|
||||||
### 2. Coverage Thresholds
|
|
||||||
The workflow checks:
|
|
||||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
|
||||||
- **Requirements by type:**
|
|
||||||
- UR (User): 23+ of 39
|
|
||||||
- IR (Integration): 5+ of 24
|
|
||||||
- DR (Development): 28+ of 48
|
|
||||||
- JA (Jellyfin API): 0+ of 3
|
|
||||||
|
|
||||||
If coverage drops below threshold, the workflow **fails** and blocks merge.
|
|
||||||
|
|
||||||
### 3. Modified File Checking
|
|
||||||
On pull requests, the workflow:
|
|
||||||
1. Detects all changed TypeScript/Svelte/Rust files
|
|
||||||
2. Warns if new/modified files lack TRACES comments
|
|
||||||
3. Suggests the TRACES format for missing comments
|
|
||||||
|
|
||||||
## How to Add Traces to New Code
|
|
||||||
|
|
||||||
When you add new code or modify existing code, include TRACES comments:
|
|
||||||
|
|
||||||
### TypeScript/Svelte Example
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function handlePlayback() {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Example
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub fn player_state_changed(state: PlayerState) {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Example
|
|
||||||
```rust
|
|
||||||
// TRACES: UR-005 | DR-001 | UT-026, UT-027
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
// Tests...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TRACES Format
|
|
||||||
|
|
||||||
```
|
|
||||||
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `UR-###` - User Requirements (features users see)
|
|
||||||
- `IR-###` - Integration Requirements (API/platform integration)
|
|
||||||
- `DR-###` - Development Requirements (internal architecture)
|
|
||||||
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- `// TRACES: UR-005` - Single requirement
|
|
||||||
- `// TRACES: UR-005, UR-026` - Multiple of same type
|
|
||||||
- `// TRACES: UR-005 | DR-029` - Multiple types
|
|
||||||
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex
|
|
||||||
|
|
||||||
## Workflow Behavior
|
|
||||||
|
|
||||||
### On Push to Main Branch
|
|
||||||
1. ✅ Extracts all traces from code
|
|
||||||
2. ✅ Validates coverage is >= 50%
|
|
||||||
3. ✅ Generates full traceability report
|
|
||||||
4. ✅ Saves report as artifact
|
|
||||||
|
|
||||||
### On Pull Request
|
|
||||||
1. ✅ Extracts all traces
|
|
||||||
2. ✅ Validates coverage >= 50%
|
|
||||||
3. ✅ Checks modified files for TRACES
|
|
||||||
4. ✅ Warns if new code lacks TRACES
|
|
||||||
5. ✅ Suggests proper format
|
|
||||||
6. ✅ Generates report artifact
|
|
||||||
|
|
||||||
### Failure Scenarios
|
|
||||||
The workflow **fails** (blocks merge) if:
|
|
||||||
- Coverage drops below 50%
|
|
||||||
- JSON extraction fails
|
|
||||||
- Invalid trace format
|
|
||||||
|
|
||||||
The workflow **warns** (but doesn't block) if:
|
|
||||||
- New files lack TRACES comments
|
|
||||||
- Coverage drops (but still above threshold)
|
|
||||||
|
|
||||||
## Viewing Reports
|
|
||||||
|
|
||||||
### In Gitea Actions UI
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click the **Traceability Validation** workflow run
|
|
||||||
3. Download **traceability-reports** artifact
|
|
||||||
4. View:
|
|
||||||
- `traces-report.json` - Raw trace data
|
|
||||||
- `docs/traceability.md` - Formatted report
|
|
||||||
|
|
||||||
### Locally
|
|
||||||
```bash
|
|
||||||
# Extract current traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Generate full report
|
|
||||||
bun run traces:markdown
|
|
||||||
cat docs/traceability.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage Goals
|
|
||||||
|
|
||||||
### Current Status
|
|
||||||
- Overall: 51% (56/114)
|
|
||||||
- UR: 59% (23/39)
|
|
||||||
- IR: 21% (5/24)
|
|
||||||
- DR: 58% (28/48)
|
|
||||||
- JA: 0% (0/3)
|
|
||||||
|
|
||||||
### Targets
|
|
||||||
- **Short term** (Sprint): Maintain ≥50% overall
|
|
||||||
- **Medium term** (Month): Reach 70% overall coverage
|
|
||||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
|
||||||
- IR requirements (API clients)
|
|
||||||
- JA requirements (Jellyfin API endpoints)
|
|
||||||
- Remaining UR/DR requirements
|
|
||||||
|
|
||||||
## Improving Coverage
|
|
||||||
|
|
||||||
### For Missing User Requirements (UR)
|
|
||||||
1. Review [README.md](../README.md) for unimplemented features
|
|
||||||
2. Add TRACES to code that implements them
|
|
||||||
3. Focus on high-priority features (High/Medium priority)
|
|
||||||
|
|
||||||
### For Missing Integration Requirements (IR)
|
|
||||||
1. Add TRACES to Jellyfin API client methods
|
|
||||||
2. Add TRACES to platform-specific backends (Android/Linux)
|
|
||||||
3. Link to corresponding Jellyfin API endpoints
|
|
||||||
|
|
||||||
### For Missing Development Requirements (DR)
|
|
||||||
1. Add TRACES to UI components in `src/lib/components/`
|
|
||||||
2. Add TRACES to composables in `src/lib/composables/`
|
|
||||||
3. Add TRACES to player backend in `src-tauri/src/player/`
|
|
||||||
|
|
||||||
### For Jellyfin API Requirements (JA)
|
|
||||||
1. Add TRACES to Jellyfin API wrapper methods
|
|
||||||
2. Document which endpoints map to which requirements
|
|
||||||
3. Link to Jellyfin API documentation
|
|
||||||
|
|
||||||
## Example PR Checklist
|
|
||||||
|
|
||||||
When submitting a pull request:
|
|
||||||
|
|
||||||
- [ ] All new code has TRACES comments linking to requirements
|
|
||||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
|
||||||
- [ ] Workflow passes (coverage ≥ 50%)
|
|
||||||
- [ ] No coverage regressions
|
|
||||||
- [ ] Artifact traceability report was generated
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Coverage below minimum threshold"
|
|
||||||
**Problem:** Workflow fails with coverage < 50%
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Run `bun run traces:json` locally
|
|
||||||
2. Check which requirements are traced
|
|
||||||
3. Add TRACES to untraced code sections
|
|
||||||
4. Re-run extraction to verify
|
|
||||||
|
|
||||||
### "New files without TRACES"
|
|
||||||
**Problem:** Workflow warns about new files lacking TRACES
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Add TRACES comments to all new code
|
|
||||||
2. Format: `// TRACES: UR-001 | DR-002`
|
|
||||||
3. Map code to specific requirements from README.md
|
|
||||||
4. Re-push
|
|
||||||
|
|
||||||
### "Invalid JSON format"
|
|
||||||
**Problem:** Trace extraction produces invalid JSON
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check for malformed TRACES comments
|
|
||||||
2. Run locally: `bun run traces:json`
|
|
||||||
3. Look for parsing errors
|
|
||||||
4. Fix and retry
|
|
||||||
|
|
||||||
## Integration with Development
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
```bash
|
|
||||||
# Check your traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Regenerate report
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
# Verify traces syntax
|
|
||||||
grep "TRACES:" src/**/*.ts src/**/*.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Your IDE
|
|
||||||
Add a file watcher to regenerate traces on save:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fileWatcher.watchPatterns": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.svelte",
|
|
||||||
"src-tauri/src/**/*.rs"
|
|
||||||
],
|
|
||||||
"fileWatcher.command": "bun run traces:markdown"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git Hooks
|
|
||||||
Add a pre-push hook to validate traces:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# .git/hooks/pre-push
|
|
||||||
bun run traces:json > /dev/null
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ Invalid TRACES format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
|
||||||
- [Requirements Specification](../README.md#requirements-specification)
|
|
||||||
- [Traceability Matrix](./traceability.md)
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check this document
|
|
||||||
2. Review example traces in `src/lib/stores/`
|
|
||||||
3. Check existing TRACES comments for format
|
|
||||||
4. Review workflow logs in Gitea Actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# TRACES Quick Reference Guide
|
|
||||||
|
|
||||||
## What are TRACES?
|
|
||||||
|
|
||||||
TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.
|
|
||||||
|
|
||||||
Format: `// TRACES: UR-001, UR-002 | DR-003`
|
|
||||||
|
|
||||||
## Quick Examples
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function handlePlayback() { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume playback from saved position
|
|
||||||
* TRACES: UR-019 | DR-022
|
|
||||||
*/
|
|
||||||
export async function resumePlayback(itemId: string) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Svelte
|
|
||||||
```svelte
|
|
||||||
<!-- TRACES: UR-007, UR-008 | DR-007 -->
|
|
||||||
<script>
|
|
||||||
export let items = [];
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub enum PlayerState { ... }
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_queue_next() {
|
|
||||||
// TRACES: UR-005 | DR-005 | UT-003
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirement Types
|
|
||||||
|
|
||||||
| Type | Meaning | Example |
|
|
||||||
|------|---------|---------|
|
|
||||||
| **UR** | User Requirement | UR-005: Control media playback |
|
|
||||||
| **IR** | Integration Requirement | IR-003: LibMPV integration |
|
|
||||||
| **DR** | Development Requirement | DR-001: Player state machine |
|
|
||||||
| **JA** | Jellyfin API Requirement | JA-007: Get playback info |
|
|
||||||
| **UT** | Unit Test | UT-001: Player state transitions |
|
|
||||||
| **IT** | Integration Test | IT-003: Audio playback via libmpv |
|
|
||||||
|
|
||||||
## Where to Find Requirements
|
|
||||||
|
|
||||||
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
|
||||||
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
|
||||||
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
|
||||||
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
|
||||||
|
|
||||||
## How to Add TRACES
|
|
||||||
|
|
||||||
### Step 1: Find the Requirement
|
|
||||||
Look up the requirement in README.md or the traceability matrix.
|
|
||||||
|
|
||||||
Example: `UR-005: Control media playback (pause, play, skip, scrub)`
|
|
||||||
|
|
||||||
### Step 2: Add Comment
|
|
||||||
Add TRACES comment at the top of the function/type/module:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005
|
|
||||||
export async function playMedia(itemId: string) {
|
|
||||||
// Implementation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Run Extraction
|
|
||||||
Verify the trace is captured:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Single Requirement
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005
|
|
||||||
function handlePlay() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Requirements, Same Type
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026, UR-019
|
|
||||||
function handlePlaybackState() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Types
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
function autoplayNextEpisode() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005 | UT-001
|
|
||||||
#[test]
|
|
||||||
fn test_player_state_transition() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modules/Files
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Player event handling
|
|
||||||
* TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
### Check Your Changes
|
|
||||||
```bash
|
|
||||||
# View current coverage
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Generate full report
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
# Check specific requirement
|
|
||||||
bun run traces:json | jq '.requirements."UR-005"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
1. Ensure all new code has TRACES
|
|
||||||
2. Format is correct: `// TRACES: ...`
|
|
||||||
3. Requirements exist in README.md
|
|
||||||
4. No typos in requirement IDs
|
|
||||||
|
|
||||||
## CI/CD Validation
|
|
||||||
|
|
||||||
The workflow automatically checks:
|
|
||||||
- ✅ Coverage stays >= 50%
|
|
||||||
- ✅ New files have TRACES
|
|
||||||
- ✅ JSON format is valid
|
|
||||||
- ✅ Reports are generated
|
|
||||||
|
|
||||||
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
|
||||||
|
|
||||||
## Tips & Tricks
|
|
||||||
|
|
||||||
### Find Related Code
|
|
||||||
```bash
|
|
||||||
# Find all code tracing to UR-005
|
|
||||||
bun run traces:json | jq '.requirements."UR-005"'
|
|
||||||
|
|
||||||
# List all tests
|
|
||||||
bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Your Editor
|
|
||||||
|
|
||||||
**VS Code:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"editor.wordBasedSuggestions": false,
|
|
||||||
"editor.suggest.custom": [
|
|
||||||
{
|
|
||||||
"name": "TRACES Format",
|
|
||||||
"insertText": "// TRACES: $1",
|
|
||||||
"insertTextRules": "InsertAsSnippet"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Find Untraced Code
|
|
||||||
```bash
|
|
||||||
# Files modified without TRACES
|
|
||||||
git diff --name-only | xargs grep -L "TRACES:" | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: Do I need TRACES on every function?**
|
|
||||||
A: Only for code that implements requirements. Internal helpers don't need TRACES.
|
|
||||||
|
|
||||||
**Q: Can I use TRACES on multiple related functions?**
|
|
||||||
A: Yes! Add at the file/module level or on individual functions.
|
|
||||||
|
|
||||||
**Q: What if code doesn't relate to any requirement?**
|
|
||||||
A: Leave it untraced. TRACES are for requirement-driven development.
|
|
||||||
|
|
||||||
**Q: How often should I regenerate reports?**
|
|
||||||
A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`
|
|
||||||
|
|
||||||
**Q: Can I trace to requirements that aren't implemented yet?**
|
|
||||||
A: Yes! TRACES show your implementation plan.
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Full Traceability Matrix](docs/traceability.md)
|
|
||||||
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
|
||||||
- [Requirements Specification](README.md)
|
|
||||||
- [Extraction Script](scripts/README.md#extract-tracests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Quick Start:**
|
|
||||||
1. Add `// TRACES: UR-XXX` to new code
|
|
||||||
2. Run `bun run traces:markdown`
|
|
||||||
3. Check `docs/traceability.md`
|
|
||||||
4. Submit PR - workflow validates automatically!
|
|
||||||
@@ -1,907 +0,0 @@
|
|||||||
# JellyTau UX Flows & Screen Transitions
|
|
||||||
|
|
||||||
This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Core Navigation Structure
|
|
||||||
|
|
||||||
### 1.1 Navigation System
|
|
||||||
|
|
||||||
JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.
|
|
||||||
|
|
||||||
**Bottom Navigation Bar (All Platforms - DR-045, UR-039):**
|
|
||||||
|
|
||||||
The bottom navigation bar is the primary navigation and is **always visible** on all platforms (mobile and desktop) except when:
|
|
||||||
- Full-screen video player is active
|
|
||||||
- User is on the login screen
|
|
||||||
|
|
||||||
**Bottom Nav Structure:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Home] [Library] [Search] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Routes:**
|
|
||||||
- **Home** → `/` (home page with carousels and featured content)
|
|
||||||
- **Library** → `/library` (library selector showing all libraries)
|
|
||||||
- **Search** → `/search` (dedicated search page)
|
|
||||||
|
|
||||||
**Note:** Available on both mobile and desktop for consistent navigation access.
|
|
||||||
|
|
||||||
**Header Navigation (Desktop):**
|
|
||||||
|
|
||||||
On desktop (md breakpoint and above), the header contains:
|
|
||||||
- Logo (links to `/library`)
|
|
||||||
- Navigation links: Home, Library, Downloads, Settings
|
|
||||||
- Search bar (inline)
|
|
||||||
- User menu: Username, Downloads icon, Logout button
|
|
||||||
|
|
||||||
**Mobile Navigation:**
|
|
||||||
|
|
||||||
On mobile, the header contains:
|
|
||||||
- Logo
|
|
||||||
- Three-dot overflow menu button (Android-style)
|
|
||||||
- Overflow menu includes:
|
|
||||||
- Downloads
|
|
||||||
- Settings
|
|
||||||
- Sign out
|
|
||||||
|
|
||||||
**Access Points Summary:**
|
|
||||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
|
||||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Initial App Launch Flow
|
|
||||||
|
|
||||||
### 2.1 First-Time Launch
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
|
|
||||||
CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
|
|
||||||
CheckAuth -->|Yes| AutoLogin[Auto-login]
|
|
||||||
|
|
||||||
LoginScreen --> EnterURL[Enter Server URL]
|
|
||||||
EnterURL --> EnterCreds[Enter Username/Password]
|
|
||||||
EnterCreds --> LoginSuccess{Success?}
|
|
||||||
LoginSuccess -->|No| LoginError[Show Error]
|
|
||||||
LoginError --> EnterCreds
|
|
||||||
LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
|
|
||||||
|
|
||||||
AutoLogin --> TokenValid{Token Valid?}
|
|
||||||
TokenValid -->|No| LoginScreen
|
|
||||||
TokenValid -->|Yes| HomePage
|
|
||||||
|
|
||||||
StoreToken --> HomePage[Home Page<br/>/]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Screens:**
|
|
||||||
1. **Login Screen** (`/login`)
|
|
||||||
- Server URL input
|
|
||||||
- Username input
|
|
||||||
- Password input
|
|
||||||
- "Remember me" checkbox (default: on)
|
|
||||||
- Login button
|
|
||||||
- No header, no bottom nav
|
|
||||||
|
|
||||||
2. **Home Page** (`/`)
|
|
||||||
- Default landing page after successful login
|
|
||||||
- Shows featured content, carousels, continue watching
|
|
||||||
- No MiniPlayer visible (nothing playing yet)
|
|
||||||
- Bottom nav: Home tab active
|
|
||||||
- Header with navigation links
|
|
||||||
|
|
||||||
### 2.2 Subsequent Launches
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Launch[App Launch] --> LoadAuth[Load Stored Token]
|
|
||||||
LoadAuth --> Validate{Token Valid?}
|
|
||||||
Validate -->|Yes| RestoreState[Restore Last Screen]
|
|
||||||
Validate -->|No| LoginScreen[Login Screen<br/>/login]
|
|
||||||
|
|
||||||
RestoreState --> CheckPlayer{Was Player<br/>Active?}
|
|
||||||
CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
|
|
||||||
CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
|
|
||||||
|
|
||||||
ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
|
|
||||||
HideMiniPlayer --> HomePage[Home Page<br/>/]
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Restoration:**
|
|
||||||
- Last viewed screen (route) is restored (defaults to `/` if none)
|
|
||||||
- If audio was playing, MiniPlayer appears at bottom
|
|
||||||
- Playback state is NOT automatically resumed (user must press play)
|
|
||||||
- Queue is restored if it existed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Audio Playback Flows
|
|
||||||
|
|
||||||
### 3.1 Starting Audio Playback
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Start[User Action] --> Action{Action Type?}
|
|
||||||
|
|
||||||
Action -->|Click Track| TrackList[TrackList Component]
|
|
||||||
Action -->|Click Album| AlbumDetail[Album Detail Page]
|
|
||||||
Action -->|Click Play on Album| AlbumPlay[Play Album Button]
|
|
||||||
|
|
||||||
TrackList --> PlayTrack[Play Single Track]
|
|
||||||
PlayTrack --> QueueAll[Queue All Filtered Tracks]
|
|
||||||
|
|
||||||
AlbumPlay --> PlayAlbum[Play All Album Tracks]
|
|
||||||
PlayAlbum --> QueueAlbum[Queue Album Tracks]
|
|
||||||
|
|
||||||
QueueAll --> InvokePlay[invoke player_play_queue]
|
|
||||||
QueueAlbum --> InvokePlay
|
|
||||||
|
|
||||||
InvokePlay --> PlayerStarts[Player State: Playing]
|
|
||||||
PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
|
|
||||||
|
|
||||||
MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Entry Points for Audio Playback:**
|
|
||||||
1. **TrackList** (`/library/music/tracks`, `/library/music/albums/[id]`)
|
|
||||||
- Click track number → Play track + queue all visible tracks
|
|
||||||
- Clicking track #3 in an album → Play track 3, queue tracks 1-10
|
|
||||||
|
|
||||||
2. **Album Card** (grid views)
|
|
||||||
- Click album → Navigate to album detail
|
|
||||||
- Play button on card → Play album immediately
|
|
||||||
|
|
||||||
3. **Search Results**
|
|
||||||
- Click track → Play track + queue search results
|
|
||||||
- Click album → Navigate to album detail
|
|
||||||
|
|
||||||
**MiniPlayer Behavior:**
|
|
||||||
- Slides up from bottom with animation (300ms)
|
|
||||||
- Height: 64px on mobile, 80px on desktop
|
|
||||||
- Shows: artwork, title, artist, play/pause, next, favorite
|
|
||||||
- Stays visible on ALL screens (except video player)
|
|
||||||
- Click anywhere on MiniPlayer → Navigate to full player
|
|
||||||
|
|
||||||
**Track Highlighting:**
|
|
||||||
When audio is playing, the currently playing track is visually highlighted in track lists and album pages:
|
|
||||||
- Subtle blue background tint
|
|
||||||
- Left border accent in Jellyfin blue
|
|
||||||
- Title text colored in Jellyfin blue
|
|
||||||
- Desktop: Animated pulsing dots indicator next to title
|
|
||||||
- Mobile: Play arrow (▶) inline with title
|
|
||||||
- Highlight updates automatically when skipping to next/previous track
|
|
||||||
|
|
||||||
### 3.2 MiniPlayer → Full Player Transition
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Mini[MiniPlayer Visible] --> UserClick{User Action}
|
|
||||||
|
|
||||||
UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
|
|
||||||
UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
|
|
||||||
|
|
||||||
NavFullPlayer --> FullPlayer[Full Audio Player Screen]
|
|
||||||
SwipeGesture --> FullPlayer
|
|
||||||
|
|
||||||
FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
|
|
||||||
|
|
||||||
ShowControls --> MiniHidden[MiniPlayer Hidden]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Full Player Screen** (`/player/[id]`)
|
|
||||||
- **Header:** Song title, artist (clickable links to artist/album pages)
|
|
||||||
- **Artwork:** Large album art (centered, dominant)
|
|
||||||
- **Progress:** Seek bar with current time / total duration
|
|
||||||
- **Controls:** Previous, Play/Pause, Next (large touch targets)
|
|
||||||
- **Secondary Controls:** Shuffle, Repeat mode, Queue, Favorite
|
|
||||||
- **Volume:** Volume slider
|
|
||||||
- **Bottom Nav:** Still visible (can navigate away while playing)
|
|
||||||
- **Back button:** Returns to previous screen, MiniPlayer reappears
|
|
||||||
|
|
||||||
### 3.3 Full Player → Back to Browsing
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
FullPlayer[Full Player Screen] --> UserAction{User Action}
|
|
||||||
|
|
||||||
UserAction -->|Back Button / Close| HistoryBack[window.history.back]
|
|
||||||
UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
|
|
||||||
|
|
||||||
HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
|
|
||||||
NavOther --> NewScreen[Navigate to New Screen]
|
|
||||||
|
|
||||||
PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
|
|
||||||
NewScreen --> MiniReappears
|
|
||||||
|
|
||||||
MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Navigation Behavior:**
|
|
||||||
- **Back Button:** Uses browser history (`window.history.back()`) to return to the previous page
|
|
||||||
- **Expected behavior:** Returns user to the screen they were on before opening full player
|
|
||||||
- **Example:** User browsing album → clicks track → full player opens → clicks back → returns to album
|
|
||||||
|
|
||||||
**Key UX Principles:**
|
|
||||||
- **Playback Never Stops:** Navigating away from player does NOT stop playback
|
|
||||||
- **MiniPlayer Persistence:** MiniPlayer visible on ALL screens (except video/login)
|
|
||||||
- **Queue Preserved:** Current queue remains intact
|
|
||||||
- **State Restoration:** Returning to full player shows same state (position, volume, etc.)
|
|
||||||
- **Natural Navigation:** Back button behaves as expected (returns to previous page, not just closes modal)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Video Playback Flows
|
|
||||||
|
|
||||||
### 4.1 Starting Video Playback
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Start[User Action] --> Action{Action Type?}
|
|
||||||
|
|
||||||
Action -->|Click Movie| MovieDetail[Movie Detail Page]
|
|
||||||
Action -->|Click Episode| EpisodeClick[Episode Click]
|
|
||||||
Action -->|Click Play Button| PlayButton[Play Button]
|
|
||||||
|
|
||||||
MovieDetail --> PlayMovie[Play Movie Button]
|
|
||||||
EpisodeClick --> PlayEpisode[Play Episode]
|
|
||||||
|
|
||||||
PlayMovie --> CheckResume{Resume<br/>Position?}
|
|
||||||
PlayEpisode --> CheckResume
|
|
||||||
|
|
||||||
CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
|
|
||||||
CheckResume -->|No| DirectPlay[Start from Beginning]
|
|
||||||
|
|
||||||
ShowDialog --> UserChoice{User Choice}
|
|
||||||
UserChoice -->|Resume| ResumePlay[Start at Saved Position]
|
|
||||||
UserChoice -->|Start Over| DirectPlay
|
|
||||||
|
|
||||||
ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
|
|
||||||
DirectPlay --> FullscreenVideo
|
|
||||||
|
|
||||||
FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resume Dialog:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Continue Watching? │
|
|
||||||
│ │
|
|
||||||
│ [Movie Title] │
|
|
||||||
│ Resume from 12:34 / 1:45:00 │
|
|
||||||
│ │
|
|
||||||
│ [Start from Beginning] [Resume] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Video Player Screen (IR-003, IR-004, UR-003)
|
|
||||||
|
|
||||||
**Initial State (First 3 seconds):**
|
|
||||||
- Controls visible overlay
|
|
||||||
- Top bar: Back button, title
|
|
||||||
- Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
|
|
||||||
- Center: Large play/pause button
|
|
||||||
|
|
||||||
**After 3 Seconds (Idle):**
|
|
||||||
- All controls fade out (500ms animation)
|
|
||||||
- Fullscreen video only
|
|
||||||
- System UI hidden (status bar, nav bar)
|
|
||||||
|
|
||||||
**User Interaction:**
|
|
||||||
- **Tap screen:** Controls reappear for 3 seconds
|
|
||||||
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
|
||||||
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
|
||||||
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
|
||||||
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
|
||||||
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
|
||||||
- **Keyboard space/K:** Toggle play/pause
|
|
||||||
- **Keyboard F:** Toggle fullscreen
|
|
||||||
- **Pinch:** Zoom (planned)
|
|
||||||
|
|
||||||
### 4.3 Exiting Video Player
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
VideoPlaying[Video Playing] --> UserAction{User Action}
|
|
||||||
|
|
||||||
UserAction -->|Back Button| StopVideo[Stop Playback]
|
|
||||||
UserAction -->|Home Button| Background[App to Background]
|
|
||||||
UserAction -->|Video Ends| VideoEnd[Playback Ended]
|
|
||||||
|
|
||||||
StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
|
|
||||||
VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
|
|
||||||
Background --> PauseVideo[Pause Video]
|
|
||||||
|
|
||||||
SaveProgress --> ExitFullscreen[Exit Fullscreen]
|
|
||||||
SaveComplete --> AutoNext{Next Episode<br/>Available?}
|
|
||||||
|
|
||||||
AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
|
|
||||||
AutoNext -->|No| ExitFullscreen
|
|
||||||
|
|
||||||
ShowCountdown --> UserCancel{User Cancels?}
|
|
||||||
UserCancel -->|Yes| ExitFullscreen
|
|
||||||
UserCancel -->|No, timeout| PlayNext[Play Next Episode]
|
|
||||||
|
|
||||||
ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
|
|
||||||
|
|
||||||
PlayNext --> VideoPlaying
|
|
||||||
|
|
||||||
PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Auto-Next Overlay:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [Episode Thumbnail] │
|
|
||||||
│ │
|
|
||||||
│ Next: S01E02 - Episode Title │
|
|
||||||
│ Starting in 5 seconds... │
|
|
||||||
│ │
|
|
||||||
│ [Cancel] [Play Now] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Music Library Navigation Flows
|
|
||||||
|
|
||||||
### 5.1 Music Category Landing Page
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
|
|
||||||
|
|
||||||
ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
|
|
||||||
|
|
||||||
MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
|
|
||||||
|
|
||||||
ShowCategories --> UserClick{User Clicks Category}
|
|
||||||
|
|
||||||
UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
|
|
||||||
UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
|
|
||||||
UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
|
|
||||||
UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
|
|
||||||
UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Category Cards:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
||||||
│ │ 🎵 │ │ 👤 │ │ 💿 │ │
|
|
||||||
│ │Track│ │Artist│ │Album│ │
|
|
||||||
│ └──────┘ └──────┘ └──────┘ │
|
|
||||||
│ ┌──────┐ ┌──────┐ │
|
|
||||||
│ │ 📝 │ │ 🎭 │ │
|
|
||||||
│ │List │ │Genre│ │
|
|
||||||
│ └──────┘ └──────┘ │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Albums View Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
|
||||||
|
|
||||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
|
||||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
|
||||||
|
|
||||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
|
||||||
|
|
||||||
ShowAlbum --> TrackAction{User Action}
|
|
||||||
|
|
||||||
TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
|
|
||||||
TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
|
|
||||||
TrackAction -->|Download Album| DownloadFlow[Download Flow]
|
|
||||||
TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Album Detail Layout:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [←] [♡] [⬇] │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────┐ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Album Artwork │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Album Title │
|
|
||||||
│ Artist Name (clickable) │
|
|
||||||
│ 2024 • 12 tracks • 45:23 │
|
|
||||||
│ │
|
|
||||||
│ [▶ Play] [🔀 Shuffle] │
|
|
||||||
│ │
|
|
||||||
│ ───────────────────────────────────── │
|
|
||||||
│ 1 Track Title 3:45 │
|
|
||||||
│ 2 Track Title 4:12 │
|
|
||||||
│ 3 Track Title 3:28 │
|
|
||||||
│ ... │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Artist Navigation
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
|
|
||||||
|
|
||||||
ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
|
|
||||||
|
|
||||||
ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
|
|
||||||
|
|
||||||
ShowContent --> UserAction{User Action}
|
|
||||||
|
|
||||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page]
|
|
||||||
UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
|
|
||||||
UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Search Flow
|
|
||||||
|
|
||||||
### 6.1 Search Page Navigation
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
|
||||||
|
|
||||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
|
||||||
|
|
||||||
SearchPage --> EmptyState{Has Query?}
|
|
||||||
|
|
||||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
|
||||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
|
||||||
|
|
||||||
ShowPrompt --> UserTypes[User Types in Search]
|
|
||||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
|
||||||
LiveSearch --> ShowResults
|
|
||||||
|
|
||||||
ShowResults --> UserClick{User Clicks Result}
|
|
||||||
|
|
||||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
|
||||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
|
||||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
|
||||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Search Page Layout:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [🔍 Search...] [✕] │
|
|
||||||
│ │
|
|
||||||
│ Songs ──────────────────────────── │
|
|
||||||
│ ♪ Song Title - Artist 3:45 │
|
|
||||||
│ ♪ Song Title - Artist 4:12 │
|
|
||||||
│ See all (23) │
|
|
||||||
│ │
|
|
||||||
│ Albums ─────────────────────────── │
|
|
||||||
│ [Album Cover] Album Title │
|
|
||||||
│ [Album Cover] Album Title │
|
|
||||||
│ See all (8) │
|
|
||||||
│ │
|
|
||||||
│ Artists ────────────────────────── │
|
|
||||||
│ [Photo] Artist Name │
|
|
||||||
│ See all (5) │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Download Flows
|
|
||||||
|
|
||||||
### 7.1 Initiating Downloads
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
User[User on Album/Track Page] --> ClickDownload[Click Download Button]
|
|
||||||
|
|
||||||
ClickDownload --> CheckType{Download Type?}
|
|
||||||
|
|
||||||
CheckType -->|Single Track| DownloadTrack[Download Single File]
|
|
||||||
CheckType -->|Album| DownloadAlbum[Download All Tracks]
|
|
||||||
CheckType -->|Artist| ShowOptions[Show Options Dialog]
|
|
||||||
|
|
||||||
ShowOptions --> UserChoice{User Choice}
|
|
||||||
UserChoice -->|Discography| DownloadAll[Download All Albums]
|
|
||||||
UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
|
|
||||||
|
|
||||||
DownloadTrack --> QueueDownload[Queue in Download Manager]
|
|
||||||
DownloadAlbum --> QueueMultiple[Queue Multiple Files]
|
|
||||||
|
|
||||||
QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
|
|
||||||
QueueMultiple --> ShowProgress
|
|
||||||
|
|
||||||
ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Download Button States:**
|
|
||||||
```
|
|
||||||
States:
|
|
||||||
1. [⬇] Available - Gray outline
|
|
||||||
2. [○ 45%] Downloading - Blue ring progress
|
|
||||||
3. [✓] Downloaded - Green checkmark
|
|
||||||
4. [!] Failed - Red with retry option
|
|
||||||
5. [⏸] Paused - Yellow pause icon
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Managing Downloads Page
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
User[User] --> NavChoice{Navigation Path}
|
|
||||||
|
|
||||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
|
||||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
|
||||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
|
||||||
|
|
||||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
|
||||||
HeaderIcon --> DownloadsPage
|
|
||||||
TypeURL --> DownloadsPage
|
|
||||||
|
|
||||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
|
||||||
|
|
||||||
ShowTabs --> ActiveTab{Active Tab}
|
|
||||||
|
|
||||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
|
||||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
|
||||||
|
|
||||||
ShowActive --> UserAction1{User Action}
|
|
||||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
|
||||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
|
||||||
|
|
||||||
ShowCompleted --> UserAction2{User Action}
|
|
||||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
|
||||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Navigation to Downloads:**
|
|
||||||
- **Desktop:** Click "Downloads" link in header navigation
|
|
||||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
|
||||||
- **Direct:** Navigate to `/downloads` route
|
|
||||||
|
|
||||||
**Downloads Page Layout:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [←] Downloads │
|
|
||||||
│ │
|
|
||||||
│ [Active (3)] [Completed (12)] │
|
|
||||||
│ │
|
|
||||||
│ ─ Downloading ──────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ [████████░░] 80% │
|
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
|
||||||
│ │
|
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ [██░░░░░░░░] 20% │
|
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
|
||||||
│ │
|
|
||||||
│ ─ Queued ───────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ Waiting... │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Settings & Account Flows
|
|
||||||
|
|
||||||
### 8.1 Settings Navigation
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
User[User] --> NavChoice{Navigation Path}
|
|
||||||
|
|
||||||
NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
|
|
||||||
NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
|
|
||||||
NavChoice -->|Direct| TypeURL[Navigate to /settings]
|
|
||||||
|
|
||||||
HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
|
|
||||||
OverflowMenu --> SettingsPage
|
|
||||||
TypeURL --> SettingsPage
|
|
||||||
|
|
||||||
SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
|
|
||||||
|
|
||||||
ShowSections --> UserClick{User Clicks Section}
|
|
||||||
|
|
||||||
UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
|
|
||||||
UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
|
|
||||||
UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
|
|
||||||
UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Navigation to Settings:**
|
|
||||||
- **Desktop:** Click "Settings" link in header navigation
|
|
||||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
|
||||||
- **Direct:** Navigate to `/settings` route
|
|
||||||
|
|
||||||
### 8.2 Logout Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
|
|
||||||
|
|
||||||
ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
|
|
||||||
|
|
||||||
ConfirmDialog --> UserConfirm{User Confirms?}
|
|
||||||
|
|
||||||
UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
|
|
||||||
UserConfirm -->|Yes| StopPlayer[Stop Playback]
|
|
||||||
|
|
||||||
StopPlayer --> ClearToken[Delete Token from Keyring]
|
|
||||||
ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
|
|
||||||
|
|
||||||
ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
|
|
||||||
|
|
||||||
NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logout Button Location:**
|
|
||||||
- Always visible in header user menu (logout icon)
|
|
||||||
- Accessible from any authenticated screen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Background & Lock Screen Behavior
|
|
||||||
|
|
||||||
### 9.1 Audio Playback in Background (Android)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Playing[Audio Playing] --> Background{User Action}
|
|
||||||
|
|
||||||
Background -->|Home Button| AppBackground[App to Background]
|
|
||||||
Background -->|Screen Lock| ScreenLock[Screen Locked]
|
|
||||||
|
|
||||||
AppBackground --> ContinuePlay[Playback Continues]
|
|
||||||
ScreenLock --> ContinuePlay
|
|
||||||
|
|
||||||
ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
|
|
||||||
|
|
||||||
ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
|
|
||||||
|
|
||||||
LockScreen --> UserInteract{User Interaction}
|
|
||||||
|
|
||||||
UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
|
|
||||||
UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
|
|
||||||
UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notification Layout (Android):**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Artwork] Song Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ Album Name │
|
|
||||||
│ │
|
|
||||||
│ [⏮] [⏸] [⏭] [✕] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 Video Playback in Background
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
VideoPlaying[Video Playing] --> Background{User Action}
|
|
||||||
|
|
||||||
Background -->|Home Button| AutoPause[Automatically Pause]
|
|
||||||
Background -->|Screen Lock| AutoPause
|
|
||||||
|
|
||||||
AutoPause --> SaveProgress[Save Progress]
|
|
||||||
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
|
||||||
|
|
||||||
ShowNotification --> UserReturn{User Returns?}
|
|
||||||
|
|
||||||
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
|
|
||||||
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
|
||||||
|
|
||||||
ResumeVideo --> AskResume[Resume from Saved Position]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Error States & Edge Cases
|
|
||||||
|
|
||||||
### 10.1 Network Loss During Streaming
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
|
|
||||||
|
|
||||||
LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
|
|
||||||
|
|
||||||
CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
|
|
||||||
CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
|
|
||||||
|
|
||||||
ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
|
|
||||||
|
|
||||||
WaitReconnect --> Reconnect{Reconnected?}
|
|
||||||
|
|
||||||
Reconnect -->|Yes| Resume[Resume Streaming]
|
|
||||||
Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
|
|
||||||
|
|
||||||
ShowError --> OfferRetry[Offer Retry Button]
|
|
||||||
ShowError --> OfferDownload[Offer "Download for Offline"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.2 Server Unreachable
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Action[User Action Requires Server] --> TryConnect[Attempt Connection]
|
|
||||||
|
|
||||||
TryConnect --> Timeout{Connection<br/>Timeout?}
|
|
||||||
|
|
||||||
Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
|
|
||||||
Timeout -->|No| Success[Action Succeeds]
|
|
||||||
|
|
||||||
ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.3 Download Failed
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Downloading[Download in Progress] --> Failure{Failure Type?}
|
|
||||||
|
|
||||||
Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
|
|
||||||
Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
|
|
||||||
Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
|
|
||||||
|
|
||||||
Retry --> RetryCount{Retry Count<br/>< 3?}
|
|
||||||
RetryCount -->|Yes| Downloading
|
|
||||||
RetryCount -->|No| Failed[Mark as Failed]
|
|
||||||
|
|
||||||
ShowDiskError --> Failed
|
|
||||||
ShowServerError --> Failed
|
|
||||||
|
|
||||||
Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Platform-Specific UX Patterns
|
|
||||||
|
|
||||||
### 11.1 Android-Specific
|
|
||||||
|
|
||||||
**Hardware Back Button:**
|
|
||||||
- **In Full Player:** Return to previous screen, show MiniPlayer
|
|
||||||
- **In Video Player:** Stop playback, exit fullscreen
|
|
||||||
- **In Album Detail:** Return to library grid
|
|
||||||
- **At Library Home:** Exit app (show confirmation)
|
|
||||||
|
|
||||||
**System Volume Buttons:**
|
|
||||||
- **While playing audio:** Adjust playback volume
|
|
||||||
- **While controlling remote session:** Adjust remote session volume (shows session name in volume panel)
|
|
||||||
- **In menus:** Adjust system volume (default behavior)
|
|
||||||
|
|
||||||
**Share Integration:**
|
|
||||||
- Long-press album/song → Share menu
|
|
||||||
- Options: Share with other apps, Copy link
|
|
||||||
|
|
||||||
### 11.2 Linux Desktop-Specific
|
|
||||||
|
|
||||||
**Keyboard Shortcuts:**
|
|
||||||
- `Space`: Play/Pause
|
|
||||||
- `→`: Next track
|
|
||||||
- `←`: Previous track
|
|
||||||
- `/`: Focus search
|
|
||||||
- `Ctrl+Q`: Quit
|
|
||||||
|
|
||||||
**Window Behavior:**
|
|
||||||
- Minimize to tray (playback continues)
|
|
||||||
- Close window (show confirmation if playing)
|
|
||||||
- MPRIS integration for desktop media controls
|
|
||||||
|
|
||||||
**Mouse Interactions:**
|
|
||||||
- Hover over MiniPlayer: Show additional controls (volume, queue peek)
|
|
||||||
- Right-click: Context menu (Add to playlist, Go to artist, Download)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. UX Principles Summary
|
|
||||||
|
|
||||||
### 12.1 Core Principles
|
|
||||||
|
|
||||||
1. **Playback Persistence:**
|
|
||||||
- Audio playback never stops unless user explicitly stops it
|
|
||||||
- MiniPlayer visible on all screens (except video/login)
|
|
||||||
- Queue and position preserved across navigation
|
|
||||||
|
|
||||||
2. **Non-Blocking UI:**
|
|
||||||
- Downloads happen in background
|
|
||||||
- Sync operations never block user interaction
|
|
||||||
- Optimistic updates (favorite, progress) with background sync
|
|
||||||
|
|
||||||
3. **Offline-First:**
|
|
||||||
- Downloaded content works offline
|
|
||||||
- Seamless switch between online/offline
|
|
||||||
- Progress and preferences saved locally
|
|
||||||
|
|
||||||
4. **Progressive Disclosure:**
|
|
||||||
- Simple defaults, advanced options hidden
|
|
||||||
- Context menus for secondary actions
|
|
||||||
- Settings organized by category
|
|
||||||
|
|
||||||
5. **Responsive Design:**
|
|
||||||
- Mobile-first UI
|
|
||||||
- Desktop enhancements (hover states, keyboard shortcuts)
|
|
||||||
- Tablet: Grid layouts with more columns
|
|
||||||
|
|
||||||
### 12.2 Animation & Transitions
|
|
||||||
|
|
||||||
| Transition | Duration | Easing |
|
|
||||||
|------------|----------|--------|
|
|
||||||
| MiniPlayer slide up/down | 300ms | ease-out |
|
|
||||||
| Screen navigation | 200ms | ease-in-out |
|
|
||||||
| Video controls fade | 500ms | ease-out |
|
|
||||||
| Download button state change | 150ms | ease-in-out |
|
|
||||||
| Modal appear | 200ms | ease-out |
|
|
||||||
| Toast notification | 250ms | ease-in-out |
|
|
||||||
|
|
||||||
### 12.3 Touch Targets (Mobile)
|
|
||||||
|
|
||||||
| Element | Minimum Size |
|
|
||||||
|---------|--------------|
|
|
||||||
| Bottom nav buttons | 48x48 dp |
|
|
||||||
| List item (track, album) | Full width x 56 dp |
|
|
||||||
| Player controls | 56x56 dp |
|
|
||||||
| MiniPlayer | Full width x 64 dp |
|
|
||||||
| Download button | 40x40 dp |
|
|
||||||
| Favorite button | 40x40 dp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Future UX Enhancements
|
|
||||||
|
|
||||||
### 13.1 Planned Features
|
|
||||||
|
|
||||||
1. **Gesture Navigation:**
|
|
||||||
- Swipe up on MiniPlayer → Full player
|
|
||||||
- Swipe down on full player → Back to previous screen
|
|
||||||
- Swipe between tracks in full player
|
|
||||||
|
|
||||||
2. **Queue Management UI (DR-020):**
|
|
||||||
- Drag to reorder
|
|
||||||
- Swipe to remove
|
|
||||||
- Add to queue vs. Play next
|
|
||||||
|
|
||||||
3. **Sleep Timer (UR-026):**
|
|
||||||
- Accessible from full player menu
|
|
||||||
- Presets: 15min, 30min, 1hr, End of track, End of album
|
|
||||||
- Countdown visible in MiniPlayer
|
|
||||||
|
|
||||||
4. **Home Screen (UR-034):**
|
|
||||||
- Hero banner carousel
|
|
||||||
- Continue watching/listening
|
|
||||||
- Recently added
|
|
||||||
- Personalized recommendations
|
|
||||||
|
|
||||||
5. **Cast/Remote Control Enhancements:**
|
|
||||||
- Picture-in-picture for remote sessions
|
|
||||||
- Multi-room audio (play on multiple devices)
|
|
||||||
- Handoff (transfer playback to phone from TV)
|
|
||||||
|
|
||||||
### 13.2 Accessibility Enhancements
|
|
||||||
|
|
||||||
- Screen reader optimization
|
|
||||||
- High contrast mode
|
|
||||||
- Larger text option
|
|
||||||
- Voice control integration
|
|
||||||
- Haptic feedback for controls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# E2E Test Configuration
|
|
||||||
# Copy this file to .env and fill in your test credentials
|
|
||||||
|
|
||||||
# Jellyfin Server Configuration
|
|
||||||
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
|
||||||
TEST_SERVER_NAME=Demo Server
|
|
||||||
|
|
||||||
# Test User Credentials
|
|
||||||
TEST_USERNAME=demo
|
|
||||||
TEST_PASSWORD=
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs (for testing playback, etc.)
|
|
||||||
# You can find these IDs in your Jellyfin server
|
|
||||||
TEST_MUSIC_LIBRARY_ID=
|
|
||||||
TEST_MOVIE_LIBRARY_ID=
|
|
||||||
TEST_ARTIST_ID=
|
|
||||||
TEST_ALBUM_ID=
|
|
||||||
TEST_TRACK_ID=
|
|
||||||
TEST_MOVIE_ID=
|
|
||||||
TEST_EPISODE_ID=
|
|
||||||
|
|
||||||
# Test Timeouts (milliseconds)
|
|
||||||
TEST_TIMEOUT=60000
|
|
||||||
TEST_WAIT_TIMEOUT=15000
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
# E2E Testing with WebdriverIO
|
|
||||||
|
|
||||||
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Configure test credentials (first time only)
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
# Edit e2e/.env with your Jellyfin server details
|
|
||||||
|
|
||||||
# 2. Build the frontend
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# 3. Run E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Test Credentials
|
|
||||||
|
|
||||||
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp e2e/.env.example e2e/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**e2e/.env** (your private file):
|
|
||||||
```bash
|
|
||||||
# Your Jellyfin test server
|
|
||||||
TEST_SERVER_URL=https://your-jellyfin.example.com
|
|
||||||
TEST_SERVER_NAME=My Test Server
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_USERNAME=testuser
|
|
||||||
TEST_PASSWORD=yourpassword
|
|
||||||
|
|
||||||
# Optional: Specific test data IDs
|
|
||||||
TEST_MUSIC_LIBRARY_ID=abc123
|
|
||||||
TEST_ALBUM_ID=xyz789
|
|
||||||
# ... etc
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- ✅ `.env` is gitignored - your credentials stay private
|
|
||||||
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
|
||||||
- ✅ Share `.env.example` with your team so they can set up their own
|
|
||||||
|
|
||||||
### Isolated Test Database
|
|
||||||
|
|
||||||
**Your production data is safe!** E2E tests use a completely separate database:
|
|
||||||
|
|
||||||
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
|
||||||
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
|
||||||
|
|
||||||
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
e2e/
|
|
||||||
├── .env.example # Template for test credentials
|
|
||||||
├── .env # Your credentials (gitignored)
|
|
||||||
├── specs/ # Test specifications
|
|
||||||
│ ├── app-launch.e2e.ts # App initialization tests
|
|
||||||
│ ├── auth.e2e.ts # Authentication flow
|
|
||||||
│ └── navigation.e2e.ts # Navigation and routing
|
|
||||||
├── pageobjects/ # Page Object Model (POM)
|
|
||||||
│ ├── BasePage.ts # Base class with common methods
|
|
||||||
│ ├── LoginPage.ts # Login page interactions
|
|
||||||
│ └── HomePage.ts # Home page interactions
|
|
||||||
└── helpers/ # Test utilities
|
|
||||||
├── testConfig.ts # Load .env configuration
|
|
||||||
└── testSetup.ts # Setup helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Page Object Model
|
|
||||||
|
|
||||||
Tests use the Page Object Model pattern for maintainability:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Good: Using page objects
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Bad: Direct selectors in tests
|
|
||||||
await $("#server-url").setValue("https://...");
|
|
||||||
await $("button").click();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing Tests
|
|
||||||
|
|
||||||
### Using Test Configuration
|
|
||||||
|
|
||||||
Always use `testConfig` for credentials and server details:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("My Feature", () => {
|
|
||||||
it("should test something", async () => {
|
|
||||||
// Use testConfig instead of hardcoded values
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Access optional test data
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
// Test with specific album
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Data IDs
|
|
||||||
|
|
||||||
For tests that need specific content (albums, tracks, etc.):
|
|
||||||
|
|
||||||
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
|
||||||
2. Add it to your `e2e/.env`:
|
|
||||||
```bash
|
|
||||||
TEST_ALBUM_ID=abc123def456
|
|
||||||
```
|
|
||||||
3. Use it in tests:
|
|
||||||
```typescript
|
|
||||||
if (testConfig.albumId) {
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Test
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Album Playback", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Login before each test
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should play an album", async () => {
|
|
||||||
// Skip if no test album configured
|
|
||||||
if (!testConfig.albumId) {
|
|
||||||
console.log("Skipping - no TEST_ALBUM_ID configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to album
|
|
||||||
await browser.url(`/album/${testConfig.albumId}`);
|
|
||||||
|
|
||||||
// Click play
|
|
||||||
const playButton = await $('[aria-label="Play"]');
|
|
||||||
await playButton.click();
|
|
||||||
|
|
||||||
// Verify playback started
|
|
||||||
const miniPlayer = await $(".mini-player");
|
|
||||||
expect(await miniPlayer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all E2E tests
|
|
||||||
bun run test:e2e
|
|
||||||
|
|
||||||
# Run in watch mode (development)
|
|
||||||
bun run test:e2e:dev
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Before Running
|
|
||||||
|
|
||||||
**Always build the frontend first:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
cd src-tauri && cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
The debug binary expects built frontend files in the `build/` directory.
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
### app-launch.e2e.ts
|
|
||||||
Basic app initialization tests:
|
|
||||||
- App launches successfully
|
|
||||||
- UI renders correctly
|
|
||||||
- Unauthenticated users redirect to login
|
|
||||||
|
|
||||||
**Status:** ✅ Working (no credentials needed)
|
|
||||||
|
|
||||||
### auth.e2e.ts
|
|
||||||
Full authentication flow:
|
|
||||||
- Server connection (2-step process)
|
|
||||||
- Login form validation
|
|
||||||
- Error handling
|
|
||||||
- Complete auth flow
|
|
||||||
|
|
||||||
**Status:** ✅ Working with any Jellyfin server
|
|
||||||
|
|
||||||
### navigation.e2e.ts
|
|
||||||
Routing and navigation:
|
|
||||||
- Protected routes
|
|
||||||
- Redirects
|
|
||||||
- Navigation after login
|
|
||||||
|
|
||||||
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
### wdio.conf.ts
|
|
||||||
|
|
||||||
Main WebdriverIO configuration:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
port: 4444, // tauri-driver port
|
|
||||||
maxInstances: 1, // Run tests sequentially
|
|
||||||
logLevel: "warn", // Reduce noise
|
|
||||||
framework: "mocha",
|
|
||||||
timeout: 60000, // 60s test timeout
|
|
||||||
|
|
||||||
capabilities: [{
|
|
||||||
"tauri:options": {
|
|
||||||
application: "path/to/app",
|
|
||||||
env: {
|
|
||||||
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
|
||||||
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
|
||||||
| `TEST_USERNAME` | Test user username | `demo` |
|
|
||||||
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
|
||||||
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
|
||||||
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
|
||||||
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
|
||||||
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
|
||||||
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### View Application During Tests
|
|
||||||
|
|
||||||
Tests run with a visible window. To pause and inspect:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
it("debug test", async () => {
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Pause for 10 seconds to inspect
|
|
||||||
await browser.pause(10000);
|
|
||||||
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Logs
|
|
||||||
|
|
||||||
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
|
||||||
- **tauri-driver logs:** Stdout/stderr from driver process
|
|
||||||
- **App logs:** Check app console (if running with dev tools)
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**"Connection refused" in browser body**
|
|
||||||
- Frontend not built: Run `bun run build`
|
|
||||||
- Solution: Always build before testing
|
|
||||||
|
|
||||||
**"Element not found" errors**
|
|
||||||
- Selector might be wrong
|
|
||||||
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
|
||||||
|
|
||||||
**"Invalid session id"**
|
|
||||||
- Normal when app closes between tests
|
|
||||||
- Each test file gets a fresh app instance
|
|
||||||
|
|
||||||
**Tests fail with "no .env file"**
|
|
||||||
- Copy `e2e/.env.example` to `e2e/.env`
|
|
||||||
- Configure your Jellyfin server details
|
|
||||||
|
|
||||||
**Database still using production data**
|
|
||||||
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
|
||||||
- Rebuild app: `cd src-tauri && cargo build`
|
|
||||||
|
|
||||||
## Platform Support
|
|
||||||
|
|
||||||
### Supported
|
|
||||||
|
|
||||||
- ✅ **Linux** - Primary development platform
|
|
||||||
- ✅ **Windows** - Supported (paths auto-detected)
|
|
||||||
- ✅ **macOS** - Supported (paths auto-detected)
|
|
||||||
|
|
||||||
### Not Supported
|
|
||||||
|
|
||||||
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
|
||||||
- Desktop tests cover 90% of app logic anyway
|
|
||||||
|
|
||||||
## Team Collaboration
|
|
||||||
|
|
||||||
### Sharing Test Configuration
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- ✅ Commit `e2e/.env.example` with template values
|
|
||||||
- ✅ Update README when adding new test data requirements
|
|
||||||
- ✅ Use descriptive variable names in `.env.example`
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- ❌ Commit `e2e/.env` with real credentials
|
|
||||||
- ❌ Hardcode server URLs in test files
|
|
||||||
- ❌ Skip authentication in tests (always test full flows)
|
|
||||||
|
|
||||||
### Setting Up for a New Team Member
|
|
||||||
|
|
||||||
1. **Clone repo**
|
|
||||||
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
|
||||||
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
|
||||||
4. **Build frontend:** `bun run build`
|
|
||||||
5. **Run tests:** `bun run test:e2e`
|
|
||||||
|
|
||||||
That's it! No shared credentials needed.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Use testConfig:** Never hardcode credentials
|
|
||||||
2. **Use Page Objects:** Keep selectors out of test specs
|
|
||||||
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
|
||||||
4. **Independent Tests:** Each test should work standalone
|
|
||||||
5. **Skip Gracefully:** Check for optional test data before using
|
|
||||||
6. **Build First:** Always `bun run build` before running tests
|
|
||||||
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
|
||||||
- [ ] Create test data fixtures
|
|
||||||
- [ ] Add visual regression testing
|
|
||||||
- [ ] Mock Jellyfin API for faster, more reliable tests
|
|
||||||
- [ ] CI/CD integration (GitHub Actions)
|
|
||||||
- [ ] Test report generation
|
|
||||||
- [ ] Screenshot capture on failure
|
|
||||||
- [ ] Video recording of test runs
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [WebdriverIO Documentation](https://webdriver.io/)
|
|
||||||
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
|
||||||
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
|
||||||
- [Mocha Documentation](https://mochajs.org/)
|
|
||||||
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test configuration loaded from .env file
|
|
||||||
*/
|
|
||||||
export interface TestConfig {
|
|
||||||
serverUrl: string;
|
|
||||||
serverName: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
musicLibraryId?: string;
|
|
||||||
movieLibraryId?: string;
|
|
||||||
artistId?: string;
|
|
||||||
albumId?: string;
|
|
||||||
trackId?: string;
|
|
||||||
movieId?: string;
|
|
||||||
episodeId?: string;
|
|
||||||
timeout: number;
|
|
||||||
waitTimeout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load test configuration from .env file
|
|
||||||
* Falls back to demo server if .env doesn't exist
|
|
||||||
*/
|
|
||||||
export function loadTestConfig(): TestConfig {
|
|
||||||
const envPath = path.join(__dirname, "..", ".env");
|
|
||||||
const config: TestConfig = {
|
|
||||||
serverUrl: "https://demo.jellyfin.org/stable",
|
|
||||||
serverName: "Demo Server",
|
|
||||||
username: "demo",
|
|
||||||
password: "",
|
|
||||||
timeout: 60000,
|
|
||||||
waitTimeout: 15000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load .env file
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
|
||||||
const lines = envContent.split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (line.trim().startsWith("#") || !line.trim()) continue;
|
|
||||||
|
|
||||||
const [key, ...valueParts] = line.split("=");
|
|
||||||
const value = valueParts.join("=").trim();
|
|
||||||
|
|
||||||
switch (key.trim()) {
|
|
||||||
case "TEST_SERVER_URL":
|
|
||||||
if (value) config.serverUrl = value;
|
|
||||||
break;
|
|
||||||
case "TEST_SERVER_NAME":
|
|
||||||
if (value) config.serverName = value;
|
|
||||||
break;
|
|
||||||
case "TEST_USERNAME":
|
|
||||||
if (value) config.username = value;
|
|
||||||
break;
|
|
||||||
case "TEST_PASSWORD":
|
|
||||||
config.password = value; // Can be empty
|
|
||||||
break;
|
|
||||||
case "TEST_MUSIC_LIBRARY_ID":
|
|
||||||
if (value) config.musicLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_LIBRARY_ID":
|
|
||||||
if (value) config.movieLibraryId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ARTIST_ID":
|
|
||||||
if (value) config.artistId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_ALBUM_ID":
|
|
||||||
if (value) config.albumId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TRACK_ID":
|
|
||||||
if (value) config.trackId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_MOVIE_ID":
|
|
||||||
if (value) config.movieId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_EPISODE_ID":
|
|
||||||
if (value) config.episodeId = value;
|
|
||||||
break;
|
|
||||||
case "TEST_TIMEOUT":
|
|
||||||
if (value) config.timeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
case "TEST_WAIT_TIMEOUT":
|
|
||||||
if (value) config.waitTimeout = parseInt(value, 10);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ No e2e/.env file found. Using demo server credentials."
|
|
||||||
);
|
|
||||||
console.warn(
|
|
||||||
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a singleton instance
|
|
||||||
export const testConfig = loadTestConfig();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the JellyTau database and cache before tests
|
|
||||||
* This ensures each test run starts with a fresh state
|
|
||||||
*/
|
|
||||||
export function clearAppData() {
|
|
||||||
const appDataDir = path.join(
|
|
||||||
os.homedir(),
|
|
||||||
".local/share/com.dtourolle.jellytau"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(appDataDir)) {
|
|
||||||
// Remove database file
|
|
||||||
const dbPath = path.join(appDataDir, "jellytau.db");
|
|
||||||
if (fs.existsSync(dbPath)) {
|
|
||||||
fs.unlinkSync(dbPath);
|
|
||||||
console.log("Cleared test database");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any cache files if needed
|
|
||||||
// Add more cleanup as needed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to clear app data:", error);
|
|
||||||
// Don't fail tests if cleanup fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for element with retries
|
|
||||||
* Useful for elements that might take time to appear
|
|
||||||
*/
|
|
||||||
export async function waitForElement(
|
|
||||||
selector: string,
|
|
||||||
timeout: number = 15000,
|
|
||||||
retries: number = 3
|
|
||||||
): Promise<WebdriverIO.Element> {
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
} catch (error) {
|
|
||||||
if (i === retries - 1) throw error;
|
|
||||||
await browser.pause(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export default class BasePage {
|
|
||||||
async waitForElement(selector: string, timeout: number = 10000) {
|
|
||||||
const element = await $(selector);
|
|
||||||
await element.waitForDisplayed({ timeout });
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickElement(selector: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterText(selector: string, text: string) {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
await element.setValue(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getText(selector: string): Promise<string> {
|
|
||||||
const element = await this.waitForElement(selector);
|
|
||||||
return await element.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async isElementDisplayed(selector: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const element = await $(selector);
|
|
||||||
return await element.isDisplayed();
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class HomePage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get loadingSpinner() {
|
|
||||||
return $(".animate-spin");
|
|
||||||
}
|
|
||||||
|
|
||||||
get browseLibrariesButton() {
|
|
||||||
return $("button*=Browse all libraries");
|
|
||||||
}
|
|
||||||
|
|
||||||
get offlineBanner() {
|
|
||||||
return $(".bg-amber-600\\/90");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carousel sections
|
|
||||||
get heroSection() {
|
|
||||||
return $("div"); // Hero banner would need specific selector
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForHomePageLoad(timeout: number = 15000) {
|
|
||||||
// Wait for loading spinner to disappear
|
|
||||||
try {
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
|
||||||
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
|
||||||
} catch {
|
|
||||||
// Spinner might not appear if page loads quickly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOffline(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.offlineBanner.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickBrowseLibraries() {
|
|
||||||
await this.browseLibrariesButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasContent(): Promise<boolean> {
|
|
||||||
// Check if browse button exists (indicates loaded state)
|
|
||||||
try {
|
|
||||||
return await this.browseLibrariesButton.isExisting();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new HomePage();
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import BasePage from "./BasePage";
|
|
||||||
|
|
||||||
class LoginPage extends BasePage {
|
|
||||||
// Selectors
|
|
||||||
get pageTitle() {
|
|
||||||
return $("h1");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverUrlInput() {
|
|
||||||
return $("#server-url");
|
|
||||||
}
|
|
||||||
|
|
||||||
get connectButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get usernameInput() {
|
|
||||||
return $("#username");
|
|
||||||
}
|
|
||||||
|
|
||||||
get passwordInput() {
|
|
||||||
return $("#password");
|
|
||||||
}
|
|
||||||
|
|
||||||
get signInButton() {
|
|
||||||
return $('button[type="submit"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
get errorMessage() {
|
|
||||||
return $(".bg-red-900\\/50");
|
|
||||||
}
|
|
||||||
|
|
||||||
get backButton() {
|
|
||||||
return $("button*=Back");
|
|
||||||
}
|
|
||||||
|
|
||||||
get serverNameDisplay() {
|
|
||||||
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async waitForLoginPage(timeout: number = 10000) {
|
|
||||||
await this.serverUrlInput.waitForDisplayed({ timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterServerUrl(url: string) {
|
|
||||||
await this.serverUrlInput.setValue(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickConnect() {
|
|
||||||
await this.connectButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectToServer(url: string) {
|
|
||||||
await this.enterServerUrl(url);
|
|
||||||
await this.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition to login form
|
|
||||||
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterUsername(username: string) {
|
|
||||||
await this.usernameInput.setValue(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enterPassword(password: string) {
|
|
||||||
await this.passwordInput.setValue(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickSignIn() {
|
|
||||||
await this.signInButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
await this.enterUsername(username);
|
|
||||||
await this.enterPassword(password);
|
|
||||||
await this.clickSignIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
|
||||||
await this.waitForLoginPage();
|
|
||||||
await this.connectToServer(serverUrl);
|
|
||||||
await this.login(username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnServerStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.serverUrlInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async isOnLoginStep(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.usernameInput.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getErrorMessage(): Promise<string> {
|
|
||||||
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
|
||||||
return await this.errorMessage.getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasError(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await this.errorMessage.isDisplayed();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new LoginPage();
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
|
|
||||||
describe("Application Launch", () => {
|
|
||||||
it("should launch the application", async () => {
|
|
||||||
// Wait for body element to appear
|
|
||||||
const body = await $("body");
|
|
||||||
await body.waitForDisplayed({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Verify app launched successfully
|
|
||||||
expect(await body.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the main app container", async () => {
|
|
||||||
// The app has a root div with specific classes
|
|
||||||
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
|
||||||
|
|
||||||
// Verify the main container exists
|
|
||||||
expect(await appContainer.isExisting()).toBe(true);
|
|
||||||
expect(await appContainer.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show JellyTau branding", async () => {
|
|
||||||
// The app should show JellyTau title on login page (default state)
|
|
||||||
const title = await $("h1");
|
|
||||||
await title.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
const titleText = await title.getText();
|
|
||||||
expect(titleText).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// Wait for login page elements to appear
|
|
||||||
const serverUrlInput = await $("#server-url");
|
|
||||||
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify we're on the login page
|
|
||||||
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Authentication Flow", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Each test starts fresh - app should redirect to login
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Server Connection", () => {
|
|
||||||
it("should display the server connection form", async () => {
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server URL input field", async () => {
|
|
||||||
const serverInput = await LoginPage.serverUrlInput;
|
|
||||||
|
|
||||||
expect(await serverInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a disabled connect button when URL is empty", async () => {
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
|
|
||||||
// Button should be disabled when input is empty
|
|
||||||
expect(await connectButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable connect button when URL is entered", async () => {
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
|
|
||||||
const connectButton = await LoginPage.connectButton;
|
|
||||||
expect(await connectButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid server URL", async () => {
|
|
||||||
await LoginPage.enterServerUrl("not-a-valid-url");
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for error to appear
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should transition to login form on successful connection", async () => {
|
|
||||||
// Using configured test server
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
|
|
||||||
// Should now be on login step
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Login", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Connect to configured test server before each login test
|
|
||||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display login form after server connection", async () => {
|
|
||||||
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
|
||||||
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show server information", async () => {
|
|
||||||
// Server name and URL should be displayed
|
|
||||||
const serverName = await LoginPage.serverNameDisplay;
|
|
||||||
expect(await serverName.isDisplayed()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have back button to return to server selection", async () => {
|
|
||||||
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
|
||||||
|
|
||||||
await LoginPage.backButton.click();
|
|
||||||
await browser.pause(500);
|
|
||||||
|
|
||||||
// Should be back on server step
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable sign in button when username is empty", async () => {
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enable sign in button when username is entered", async () => {
|
|
||||||
await LoginPage.enterUsername("demo");
|
|
||||||
|
|
||||||
const signInButton = await LoginPage.signInButton;
|
|
||||||
expect(await signInButton.isEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid credentials", async () => {
|
|
||||||
await LoginPage.login("invalid-user", "wrong-password");
|
|
||||||
|
|
||||||
// Wait for error
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
expect(await LoginPage.hasError()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable this test by configuring e2e/.env with valid credentials
|
|
||||||
it.skip("should successfully login with valid credentials", async () => {
|
|
||||||
await LoginPage.login(testConfig.username, testConfig.password);
|
|
||||||
|
|
||||||
// Wait for redirect to home page
|
|
||||||
await browser.pause(3000);
|
|
||||||
|
|
||||||
// Should redirect away from login page
|
|
||||||
const currentUrl = await browser.getUrl();
|
|
||||||
expect(currentUrl).not.toContain("/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Full Authentication Flow", () => {
|
|
||||||
it("should complete full auth flow with test server", async () => {
|
|
||||||
// Test the complete flow
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
// Step 1: Enter server URL
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
|
||||||
await LoginPage.clickConnect();
|
|
||||||
|
|
||||||
// Wait for transition
|
|
||||||
await browser.pause(2000);
|
|
||||||
|
|
||||||
// Step 2: Should be on login form
|
|
||||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
|
||||||
|
|
||||||
// Step 3: Enter credentials
|
|
||||||
await LoginPage.enterUsername(testConfig.username);
|
|
||||||
await LoginPage.enterPassword(testConfig.password);
|
|
||||||
|
|
||||||
// Verify form is filled
|
|
||||||
const username = await LoginPage.usernameInput.getValue();
|
|
||||||
expect(username).toBe(testConfig.username);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect } from "@wdio/globals";
|
|
||||||
import LoginPage from "../pageobjects/LoginPage";
|
|
||||||
import HomePage from "../pageobjects/HomePage";
|
|
||||||
import { testConfig } from "../helpers/testConfig";
|
|
||||||
|
|
||||||
describe("Navigation", () => {
|
|
||||||
it("should redirect unauthenticated users to login", async () => {
|
|
||||||
// App should automatically redirect to login when not authenticated
|
|
||||||
await LoginPage.waitForLoginPage();
|
|
||||||
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent direct access to protected routes", async () => {
|
|
||||||
// Try to navigate to a protected route
|
|
||||||
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
|
||||||
await browser.pause(1000);
|
|
||||||
|
|
||||||
// Should redirect back to login
|
|
||||||
await LoginPage.waitForLoginPage(5000);
|
|
||||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// This test requires valid authentication - configure e2e/.env to enable
|
|
||||||
it.skip("should allow navigation after login", async () => {
|
|
||||||
// Login first
|
|
||||||
await LoginPage.fullLoginFlow(
|
|
||||||
testConfig.serverUrl,
|
|
||||||
testConfig.username,
|
|
||||||
testConfig.password
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for home page
|
|
||||||
await HomePage.waitForHomePageLoad();
|
|
||||||
|
|
||||||
// Should be able to navigate
|
|
||||||
expect(await HomePage.hasContent()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "0.14.0",
|
||||||
|
"notes": "\nVideo on the desktop is played by mpv, on Linux and now on Windows, and the\nbuilt-in web player is gone from every platform. Windows gets real audio\nplayback too.\n\n### 🔒 Security\n\n- **A track title can no longer run a command on Linux.** mpv was handed stream\n URLs and downloaded-file paths as a single command string, in which `;` starts\n a new command — so a file whose title tag carried one could run a program\n when it played. Every mpv command now passes its arguments separately.\n (DR-298)\n- **mpv checks the server's certificate.** It did not by default, and the\n addresses it opens carry your login token, so anyone able to intercept the\n connection could read it. It also no longer hands a failed address to\n youtube-dl. (DR-299)\n- **The app's web page can no longer reach the network.** It needed that only\n for the web video player; everything now goes through the backend, so the\n permission was just a way out for anything injected into the page.\n\n### ✨ Features\n\n- **mpv plays all video on Linux and Windows.** On Windows it draws into the app\n window under the controls; on Linux it no longer needs the experimental\n switch. Video still arrives transcoded to h264 for now — asking the server for\n the original file is the next step. (DR-235, DR-237)\n- **Windows plays audio through mpv**, so volume, the equalizer, volume\n normalization and gapless playback now work there. The installer ships\n mpv's LGPL-licensed library and its licence text. (DR-237)\n\n### 🐛 Fixes\n\n- **Subtitles and audio-track switching work in desktop video.** mpv now loads\n the subtitle list and switches subtitles and audio tracks itself; before this\n they did nothing once mpv drew the picture. (DR-023, DR-024)\n- **Downloaded songs with a space in their title play offline on Linux.**\n (DR-298)\n- **A queued download on Windows keeps the path it was saved under**, rather\n than having its separators rewritten. (DR-211)\n\n### 🧹 Removed\n\n- **The built-in web video player and the \"Native Video\" setting.** There is\n nothing left to switch between: every platform plays video natively.\n (DR-235)",
|
||||||
|
"pub_date": "2026-09-25T10:07:47Z",
|
||||||
|
"platforms": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaW1OUkUwMmdMTnRzMm1HcFlpcUtyVU5VanZZQTBCS0NuWmRiaExvL3lWcDAwWEswR2tpN0FoZnR2bkJLVFlIeGVLUXR0V20yeUdQeVhNSGtpVnNodHdvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMzI4ODkzCWZpbGU6SmVsbHlUYXVfMC4xNC4wX2FtZDY0LkFwcEltYWdlCjVESjY4ZDZadDBZckFidE1tOERsNWtIeVhJQjArOW1yNGZvQUgwdWx6NU5lcWRLNUo0dkhUazNhQVRWUkJSM0kzWHFkelpwRmNVKyt0VFE3STBxVEJnPT0K",
|
||||||
|
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.14.0/JellyTau_0.14.0_amd64.AppImage"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaWhSN3Z5b0YvQVMzRWxlWnNUT2I4WmUvelpBOFhYMTRQUWE2bUN5V3NBZGFQWXBSZjJ0MVdiczJOZE1YSGsybkptQnp6MG0rZUJoQjNCUkd3cVZpOGcwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMzI5NTk5CWZpbGU6SmVsbHlUYXVfMC4xNC4wX3g2NC1zZXR1cC5leGUKZ2xFYVZQU05wRVBKSElBUlgxcWVWRVZMM0x1VnFKbVRWc2YrSlBGOUZQOTBTNFBrdy9KU0F0eXRyWTllVFFkeUI4VGZjUFN6bWdaMy9xb0swZDdNQVE9PQo=",
|
||||||
|
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.14.0/JellyTau_0.14.0_x64-setup.exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jellytau",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "",
|
|
||||||
"type": "module",
|
|
||||||
"packageManager": "bun@1.3.5",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
||||||
"test": "vitest",
|
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"test:coverage": "vitest --coverage",
|
|
||||||
"test:e2e": "wdio run ./wdio.conf.ts",
|
|
||||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
|
||||||
"test:all": "./scripts/test-all.sh",
|
|
||||||
"test:rust": "./scripts/test-rust.sh",
|
|
||||||
"android:build": "./scripts/build-android.sh",
|
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
|
||||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
|
||||||
"android:check": "./scripts/check-android.sh",
|
|
||||||
"android:logs": "./scripts/logcat.sh",
|
|
||||||
"clean": "./scripts/clean.sh",
|
|
||||||
"tauri": "tauri",
|
|
||||||
"traces": "bun run scripts/extract-traces.ts",
|
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2",
|
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
|
||||||
"hls.js": "^1.6.15",
|
|
||||||
"svelte-dnd-action": "^0.9.69"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
|
||||||
"@sveltejs/kit": "^2.9.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tauri-apps/cli": "^2",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
|
||||||
"@vitest/ui": "^4.0.16",
|
|
||||||
"@wdio/cli": "^9.5.0",
|
|
||||||
"@wdio/local-runner": "^9.5.0",
|
|
||||||
"@wdio/mocha-framework": "^9.5.0",
|
|
||||||
"@wdio/spec-reporter": "^9.5.0",
|
|
||||||
"happy-dom": "^20.0.11",
|
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"svelte": "^5.47.1",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "~5.6.2",
|
|
||||||
"vite": "^6.0.3",
|
|
||||||
"vitest": ">=1.0.0 <5.0.0",
|
|
||||||
"webdriverio": "^9.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Development Scripts
|
|
||||||
|
|
||||||
Collection of utility scripts for building, testing, and deploying JellyTau.
|
|
||||||
|
|
||||||
## Testing Scripts
|
|
||||||
|
|
||||||
### `test-all.sh`
|
|
||||||
Run all tests (frontend + Rust backend).
|
|
||||||
```bash
|
|
||||||
./scripts/test-all.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `test-frontend.sh`
|
|
||||||
Run frontend tests only.
|
|
||||||
```bash
|
|
||||||
./scripts/test-frontend.sh # Run all tests
|
|
||||||
./scripts/test-frontend.sh --watch # Watch mode
|
|
||||||
./scripts/test-frontend.sh --ui # Open UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### `test-rust.sh`
|
|
||||||
Run Rust tests only.
|
|
||||||
```bash
|
|
||||||
./scripts/test-rust.sh # Run all tests
|
|
||||||
./scripts/test-rust.sh -- --nocapture # Show println! output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android Scripts
|
|
||||||
|
|
||||||
### `build-android.sh`
|
|
||||||
Build the Android APK.
|
|
||||||
```bash
|
|
||||||
./scripts/build-android.sh # Debug build
|
|
||||||
./scripts/build-android.sh release # Release build
|
|
||||||
```
|
|
||||||
|
|
||||||
### `deploy-android.sh`
|
|
||||||
Install APK on connected Android device.
|
|
||||||
```bash
|
|
||||||
./scripts/deploy-android.sh # Deploy debug APK
|
|
||||||
./scripts/deploy-android.sh release # Deploy release APK
|
|
||||||
```
|
|
||||||
|
|
||||||
### `build-and-deploy.sh`
|
|
||||||
Build and deploy in one command.
|
|
||||||
```bash
|
|
||||||
./scripts/build-and-deploy.sh # Build + deploy debug
|
|
||||||
./scripts/build-and-deploy.sh release # Build + deploy release
|
|
||||||
```
|
|
||||||
|
|
||||||
### `check-android.sh`
|
|
||||||
Check Android development environment setup.
|
|
||||||
```bash
|
|
||||||
./scripts/check-android.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `logcat.sh`
|
|
||||||
View Android logcat filtered for the app.
|
|
||||||
```bash
|
|
||||||
./scripts/logcat.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Traceability & Documentation
|
|
||||||
|
|
||||||
### `extract-traces.ts`
|
|
||||||
Extract requirement IDs (TRACES) from source code and generate a traceability matrix mapping requirements to implementation locations.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces # Generate markdown report
|
|
||||||
bun run traces:json # Generate JSON report
|
|
||||||
bun run traces:markdown # Save to docs/traceability.md
|
|
||||||
```
|
|
||||||
|
|
||||||
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
|
|
||||||
- Which code files implement which requirements
|
|
||||||
- Line numbers and code context
|
|
||||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
|
||||||
|
|
||||||
Example TRACES comment in code:
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
function handlePlayback() { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
See [docs/traceability.md](../docs/traceability.md) for the latest generated mapping.
|
|
||||||
|
|
||||||
### CI/CD Validation
|
|
||||||
|
|
||||||
The traceability system is integrated with Gitea Actions CI/CD:
|
|
||||||
- Automatically validates TRACES on every push and pull request
|
|
||||||
- Enforces minimum 50% coverage threshold
|
|
||||||
- Warns if new code lacks TRACES comments
|
|
||||||
- Generates traceability reports automatically
|
|
||||||
|
|
||||||
For details, see:
|
|
||||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
|
||||||
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
|
||||||
|
|
||||||
## Utility Scripts
|
|
||||||
|
|
||||||
### `clean.sh`
|
|
||||||
Clean all build artifacts.
|
|
||||||
```bash
|
|
||||||
./scripts/clean.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## NPM Script Aliases
|
|
||||||
|
|
||||||
You can also run these via npm/bun:
|
|
||||||
```bash
|
|
||||||
bun run test:all # All tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run android:build # Build Android APK
|
|
||||||
bun run android:deploy # Deploy to device
|
|
||||||
bun run android:dev # Build + deploy debug
|
|
||||||
bun run android:check # Check environment
|
|
||||||
bun run clean # Clean artifacts
|
|
||||||
```
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 JellyTau Android Development Helper"
|
|
||||||
echo "======================================"
|
|
||||||
|
|
||||||
# Setup environment
|
|
||||||
echo "Setting up environment..."
|
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
|
||||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
|
||||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
|
||||||
|
|
||||||
# Check prerequisites
|
|
||||||
echo -e "\n✓ Checking prerequisites..."
|
|
||||||
|
|
||||||
if ! command -v rustc &> /dev/null; then
|
|
||||||
echo "❌ Rust not found. Please install from https://rustup.rs"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v adb &> /dev/null; then
|
|
||||||
echo "❌ ADB not found. Please install Android SDK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$ANDROID_HOME" ]; then
|
|
||||||
echo "⚠️ ANDROID_HOME not found at $ANDROID_HOME"
|
|
||||||
echo " Please install Android SDK or update the path"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for connected devices
|
|
||||||
echo -e "\n📱 Connected devices:"
|
|
||||||
adb devices
|
|
||||||
|
|
||||||
# Menu
|
|
||||||
echo -e "\n📋 What would you like to do?"
|
|
||||||
echo "1) Run in development mode (hot reload)"
|
|
||||||
echo "2) Build debug APK"
|
|
||||||
echo "3) Build release APK"
|
|
||||||
echo "4) Install debug APK to device"
|
|
||||||
echo "5) Check environment"
|
|
||||||
read -p "Select option (1-5): " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
echo -e "\n🔨 Starting development mode..."
|
|
||||||
bun run tauri android dev
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
echo -e "\n🔨 Building debug APK..."
|
|
||||||
bun run tauri android build --debug
|
|
||||||
echo -e "\n✅ Debug APK built at:"
|
|
||||||
echo " src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
echo -e "\n🔨 Building release APK..."
|
|
||||||
bun run tauri android build
|
|
||||||
echo -e "\n✅ Release APK built at:"
|
|
||||||
echo " src-tauri/gen/android/app/build/outputs/apk/release/"
|
|
||||||
;;
|
|
||||||
4)
|
|
||||||
APK="src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
|
||||||
if [ -f "$APK" ]; then
|
|
||||||
echo -e "\n📲 Installing to device..."
|
|
||||||
adb install -r "$APK"
|
|
||||||
echo "✅ Installed!"
|
|
||||||
else
|
|
||||||
echo "❌ APK not found. Build it first (option 2)"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
5)
|
|
||||||
echo -e "\n🔍 Environment Check:"
|
|
||||||
echo " Rust: $(rustc --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " Cargo: $(cargo --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " Bun: $(bun --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " ADB: $(adb --version 2>/dev/null | head -1 || echo 'Not found')"
|
|
||||||
echo " ANDROID_HOME: $ANDROID_HOME"
|
|
||||||
echo " NDK_HOME: $NDK_HOME"
|
|
||||||
echo ""
|
|
||||||
echo " Rust Android targets:"
|
|
||||||
rustup target list 2>/dev/null | grep android | grep installed || echo " None installed"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Invalid option"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build and deploy Android APK in one command
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 Build and Deploy Android APK"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Pass all args (build type and/or --clean) through to the build script.
|
|
||||||
./scripts/build-android.sh "$@"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
|
||||||
BUILD_TYPE="debug"
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build Android APK
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Source Rust environment
|
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Set Android environment variables
|
|
||||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
|
||||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
|
|
||||||
|
|
||||||
echo "🤖 Building Android APK..."
|
|
||||||
echo "Android SDK: $ANDROID_HOME"
|
|
||||||
echo "NDK: $NDK_HOME"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Parse args: build type (debug/release) and optional --clean flag.
|
|
||||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
|
||||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
|
||||||
BUILD_TYPE="debug"
|
|
||||||
CLEAN="${CLEAN:-0}"
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--clean) CLEAN=1 ;;
|
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
|
||||||
if [ "$CLEAN" = "1" ]; then
|
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
|
||||||
npm install > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
|
||||||
echo "🔄 Syncing Android sources..."
|
|
||||||
./scripts/sync-android-sources.sh
|
|
||||||
|
|
||||||
# Step 2: Build the frontend first to avoid dev server issues
|
|
||||||
echo "🎨 Building frontend..."
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Step 2: Build Android APK
|
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
|
||||||
echo "📦 Building release APK..."
|
|
||||||
bun run tauri android build --apk true
|
|
||||||
else
|
|
||||||
echo "📦 Building debug APK..."
|
|
||||||
bun run tauri android build --apk true --debug
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ APK build complete!"
|
|
||||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build and push the JellyTau builder Docker image to your registry
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.tourolle.paris}"
|
|
||||||
REGISTRY_USER="${REGISTRY_USER:-dtourolle}"
|
|
||||||
IMAGE_NAME="jellytau-builder"
|
|
||||||
IMAGE_TAG="${1:-latest}"
|
|
||||||
FULL_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
echo "🐳 Building JellyTau Builder Image"
|
|
||||||
echo "=================================="
|
|
||||||
echo "Registry: $REGISTRY_HOST"
|
|
||||||
echo "User: $REGISTRY_USER"
|
|
||||||
echo "Image: $FULL_IMAGE_NAME"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 1: Build locally
|
|
||||||
echo "🔨 Building Docker image locally..."
|
|
||||||
docker build -f Dockerfile.builder -t ${IMAGE_NAME}:${IMAGE_TAG} .
|
|
||||||
|
|
||||||
# Step 2: Tag for registry
|
|
||||||
echo "🏷️ Tagging for registry..."
|
|
||||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
# Step 3: Login to registry (if not already logged in)
|
|
||||||
echo "🔐 Checking registry authentication..."
|
|
||||||
if ! docker info | grep -q "Username"; then
|
|
||||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
|
||||||
docker login ${REGISTRY_HOST}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 4: Push to registry
|
|
||||||
echo "📤 Pushing image to registry..."
|
|
||||||
docker push ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
|
|
||||||
echo ""
|
|
||||||
echo "Update your workflow to use:"
|
|
||||||
echo " container:"
|
|
||||||
echo " image: ${FULL_IMAGE_NAME}"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Check Android development environment
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔍 Checking Android development environment..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check ADB
|
|
||||||
if command -v adb &> /dev/null; then
|
|
||||||
echo "✅ ADB installed: $(adb version | head -1)"
|
|
||||||
else
|
|
||||||
echo "❌ ADB not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Android SDK
|
|
||||||
if [ -d "$HOME/Android/Sdk" ]; then
|
|
||||||
echo "✅ Android SDK found at: $HOME/Android/Sdk"
|
|
||||||
else
|
|
||||||
echo "❌ Android SDK not found at: $HOME/Android/Sdk"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check NDK
|
|
||||||
if [ -d "$HOME/Android/Sdk/ndk" ]; then
|
|
||||||
NDK_VERSION=$(ls "$HOME/Android/Sdk/ndk" | head -1)
|
|
||||||
echo "✅ NDK found: $NDK_VERSION"
|
|
||||||
else
|
|
||||||
echo "❌ NDK not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Rust
|
|
||||||
if command -v rustc &> /dev/null; then
|
|
||||||
echo "✅ Rust installed: $(rustc --version)"
|
|
||||||
else
|
|
||||||
echo "❌ Rust not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Cargo
|
|
||||||
if command -v cargo &> /dev/null; then
|
|
||||||
echo "✅ Cargo installed: $(cargo --version)"
|
|
||||||
else
|
|
||||||
echo "❌ Cargo not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for connected devices
|
|
||||||
echo ""
|
|
||||||
echo "📱 Connected Android devices:"
|
|
||||||
if adb devices | grep -q "device$"; then
|
|
||||||
adb devices | grep "device$"
|
|
||||||
else
|
|
||||||
echo "⚠️ No devices connected"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🔍 Environment check complete!"
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Requirements Coverage Checker
|
|
||||||
# Extracts @req tags from codebase and compares with README.md
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
REQUIREMENTS_FILE="README.md"
|
|
||||||
SOURCE_DIRS="src-tauri/ src/"
|
|
||||||
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " Requirements Coverage Report"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
|
|
||||||
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
|
|
||||||
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
|
|
||||||
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
|
|
||||||
sort -u)
|
|
||||||
|
|
||||||
total_reqs=$(echo "$requirements" | wc -l)
|
|
||||||
implemented=0
|
|
||||||
partial=0
|
|
||||||
planned=0
|
|
||||||
missing=0
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Category Breakdown:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
for category in UR IR DR JA; do
|
|
||||||
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
|
|
||||||
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Implementation Status:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
||||||
for req in $requirements; do
|
|
||||||
# Count full implementations
|
|
||||||
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
|
|
||||||
|
|
||||||
# Count partial implementations
|
|
||||||
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
# Count planned
|
|
||||||
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
if [ "$full_count" -gt 0 ]; then
|
|
||||||
echo "✅ $req: $full_count implementation(s)"
|
|
||||||
((implemented++))
|
|
||||||
elif [ "$partial_count" -gt 0 ]; then
|
|
||||||
echo "🔶 $req: $partial_count partial implementation(s)"
|
|
||||||
((partial++))
|
|
||||||
elif [ "$planned_count" -gt 0 ]; then
|
|
||||||
echo "📋 $req: Planned (not yet implemented)"
|
|
||||||
((planned++))
|
|
||||||
else
|
|
||||||
echo "❌ $req: No implementation found"
|
|
||||||
((missing++))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Summary:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
printf "Total Requirements: %3d\n" "$total_reqs"
|
|
||||||
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
|
|
||||||
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
|
|
||||||
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
|
|
||||||
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Exit code based on missing critical requirements
|
|
||||||
if [ "$missing" -gt 0 ]; then
|
|
||||||
echo "⚠️ Warning: $missing requirements have no implementation"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✨ All requirements have implementations!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Test Coverage Report
|
|
||||||
# Links test requirements to implementations
|
|
||||||
#
|
|
||||||
|
|
||||||
echo "Test Coverage Report"
|
|
||||||
echo "===================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
|
|
||||||
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
|
|
||||||
sort -u)
|
|
||||||
|
|
||||||
total_tests=0
|
|
||||||
covered=0
|
|
||||||
uncovered=0
|
|
||||||
|
|
||||||
for req in $test_reqs; do
|
|
||||||
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
|
|
||||||
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
((total_tests++))
|
|
||||||
|
|
||||||
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
|
|
||||||
echo "✅ $req: $test_count test(s), $impl_count implementation(s)"
|
|
||||||
((covered++))
|
|
||||||
elif [ "$impl_count" -eq 0 ]; then
|
|
||||||
echo "⚠️ $req: $test_count test(s) but no implementation"
|
|
||||||
((uncovered++))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Summary:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
printf "Total Test Requirements: %3d\n" "$total_tests"
|
|
||||||
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
|
|
||||||
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
|
|
||||||
echo ""
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Clean build artifacts
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🧹 Cleaning build artifacts..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Clean frontend
|
|
||||||
if [ -d "node_modules/.cache" ]; then
|
|
||||||
echo "Cleaning Vite cache..."
|
|
||||||
rm -rf node_modules/.cache
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d ".svelte-kit" ]; then
|
|
||||||
echo "Cleaning SvelteKit build..."
|
|
||||||
rm -rf .svelte-kit
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "build" ]; then
|
|
||||||
echo "Cleaning build directory..."
|
|
||||||
rm -rf build
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean Rust
|
|
||||||
echo "Cleaning Rust target..."
|
|
||||||
cd src-tauri
|
|
||||||
cargo clean
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Clean Android
|
|
||||||
if [ -d "src-tauri/gen/android" ]; then
|
|
||||||
echo "Cleaning Android build..."
|
|
||||||
cd src-tauri/gen/android
|
|
||||||
./gradlew clean 2>/dev/null || true
|
|
||||||
cd ../../..
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Clean complete!"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Deploy APK to connected Android device
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📱 Deploying to Android device..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if device is connected
|
|
||||||
if ! adb devices | grep -q "device$"; then
|
|
||||||
echo "❌ No Android device connected!"
|
|
||||||
echo "Please connect a device or start an emulator."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
|
||||||
BUILD_TYPE="${1:-debug}"
|
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
|
||||||
else
|
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if APK exists
|
|
||||||
if [ ! -f "$APK_PATH" ]; then
|
|
||||||
echo "❌ APK not found at: $APK_PATH"
|
|
||||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Installing APK: $APK_PATH"
|
|
||||||
adb install -r "$APK_PATH"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Deployment complete!"
|
|
||||||
echo "🚀 Launch the app on your device"
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Extract TRACES from source code and generate requirement mapping
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run scripts/extract-traces.ts
|
|
||||||
* bun run scripts/extract-traces.ts --format json
|
|
||||||
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
|
|
||||||
interface TraceEntry {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
context: string;
|
|
||||||
requirements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequirementMapping {
|
|
||||||
[reqId: string]: TraceEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TracesData {
|
|
||||||
timestamp: string;
|
|
||||||
totalFiles: number;
|
|
||||||
totalTraces: number;
|
|
||||||
requirements: RequirementMapping;
|
|
||||||
byType: {
|
|
||||||
UR: string[];
|
|
||||||
IR: string[];
|
|
||||||
DR: string[];
|
|
||||||
JA: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
|
||||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
|
||||||
const BASE_DIR = path.resolve(import.meta.dir, "..");
|
|
||||||
|
|
||||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
|
||||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
|
||||||
|
|
||||||
function extractRequirementIds(tracesString: string): string[] {
|
|
||||||
const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
|
|
||||||
return matches.map((m) => `${m[1]}-${m[2]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllSourceFiles(): string[] {
|
|
||||||
const baseDir = BASE_DIR;
|
|
||||||
const patterns = ["src", "src-tauri/src"];
|
|
||||||
const files: string[] = [];
|
|
||||||
|
|
||||||
function walkDir(dir: string) {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
// Skip node_modules, target, build
|
|
||||||
if (
|
|
||||||
relativePath.includes("node_modules") ||
|
|
||||||
relativePath.includes("target") ||
|
|
||||||
relativePath.includes("build") ||
|
|
||||||
relativePath.includes(".git")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
walkDir(fullPath);
|
|
||||||
} else if (
|
|
||||||
entry.name.endsWith(".ts") ||
|
|
||||||
entry.name.endsWith(".svelte") ||
|
|
||||||
entry.name.endsWith(".rs")
|
|
||||||
) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip directories we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const dir = path.join(baseDir, pattern);
|
|
||||||
if (fs.existsSync(dir)) {
|
|
||||||
walkDir(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTraces(): TracesData {
|
|
||||||
const requirementMap: RequirementMapping = {};
|
|
||||||
const byType: Record<string, Set<string>> = {
|
|
||||||
UR: new Set(),
|
|
||||||
IR: new Set(),
|
|
||||||
DR: new Set(),
|
|
||||||
JA: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let totalTraces = 0;
|
|
||||||
const baseDir = BASE_DIR;
|
|
||||||
|
|
||||||
const files = getAllSourceFiles();
|
|
||||||
|
|
||||||
for (const fullPath of files) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(fullPath, "utf-8");
|
|
||||||
const lines = content.split("\n");
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
let match;
|
|
||||||
TRACES_PATTERN.lastIndex = 0;
|
|
||||||
|
|
||||||
while ((match = TRACES_PATTERN.exec(content)) !== null) {
|
|
||||||
const tracesStr = match[1];
|
|
||||||
const reqIds = extractRequirementIds(tracesStr);
|
|
||||||
|
|
||||||
if (reqIds.length === 0) continue;
|
|
||||||
|
|
||||||
// Find line number
|
|
||||||
const beforeMatch = content.substring(0, match.index);
|
|
||||||
const lineNum = beforeMatch.split("\n").length - 1;
|
|
||||||
|
|
||||||
// Get context (function/class name if available)
|
|
||||||
let context = "Unknown";
|
|
||||||
for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (
|
|
||||||
line.includes("function ") ||
|
|
||||||
line.includes("export const ") ||
|
|
||||||
line.includes("pub fn ") ||
|
|
||||||
line.includes("pub enum ") ||
|
|
||||||
line.includes("pub struct ") ||
|
|
||||||
line.includes("impl ") ||
|
|
||||||
line.includes("async function ") ||
|
|
||||||
line.includes("class ") ||
|
|
||||||
line.includes("export type ")
|
|
||||||
) {
|
|
||||||
context = line
|
|
||||||
.trim()
|
|
||||||
.replace(/^\s*\/\/\s*/, "")
|
|
||||||
.replace(/^\s*\/\*\*\s*/, "");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: TraceEntry = {
|
|
||||||
file: relativePath,
|
|
||||||
line: lineNum + 1,
|
|
||||||
context,
|
|
||||||
requirements: reqIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const reqId of reqIds) {
|
|
||||||
if (!requirementMap[reqId]) {
|
|
||||||
requirementMap[reqId] = [];
|
|
||||||
}
|
|
||||||
requirementMap[reqId].push(entry);
|
|
||||||
|
|
||||||
// Track by type
|
|
||||||
const type = reqId.substring(0, 2);
|
|
||||||
if (byType[type]) {
|
|
||||||
byType[type].add(reqId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalTraces++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip files we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
totalFiles: files.length,
|
|
||||||
totalTraces,
|
|
||||||
requirements: requirementMap,
|
|
||||||
byType: {
|
|
||||||
UR: Array.from(byType["UR"]).sort(),
|
|
||||||
IR: Array.from(byType["IR"]).sort(),
|
|
||||||
DR: Array.from(byType["DR"]).sort(),
|
|
||||||
JA: Array.from(byType["JA"]).sort(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMarkdown(data: TracesData): string {
|
|
||||||
let md = `# Code Traceability Matrix
|
|
||||||
|
|
||||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- **Total Files Scanned:** ${data.totalFiles}
|
|
||||||
- **Total TRACES Found:** ${data.totalTraces}
|
|
||||||
- **Requirements Covered:**
|
|
||||||
- User Requirements (UR): ${data.byType.UR.length}
|
|
||||||
- Integration Requirements (IR): ${data.byType.IR.length}
|
|
||||||
- Development Requirements (DR): ${data.byType.DR.length}
|
|
||||||
- Jellyfin API Requirements (JA): ${data.byType.JA.length}
|
|
||||||
|
|
||||||
## Requirements by Type
|
|
||||||
|
|
||||||
### User Requirements (UR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.UR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Integration Requirements (IR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.IR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Development Requirements (DR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.DR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Jellyfin API Requirements (JA)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.JA.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Detailed Mapping
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Sort requirements by ID
|
|
||||||
const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
|
|
||||||
const typeA = a.substring(0, 2);
|
|
||||||
const typeB = b.substring(0, 2);
|
|
||||||
const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
|
|
||||||
if (typeOrder[typeA] !== typeOrder[typeB]) {
|
|
||||||
return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
|
|
||||||
}
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const reqId of sortedReqs) {
|
|
||||||
const entries = data.requirements[reqId];
|
|
||||||
md += `### ${reqId}\n\n`;
|
|
||||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
|
|
||||||
md += ` - **Line:** ${entry.line}\n`;
|
|
||||||
const contextPreview = entry.context.substring(0, 70);
|
|
||||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
|
||||||
}
|
|
||||||
md += "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateJson(data: TracesData): string {
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main
|
|
||||||
const args = Bun.argv.slice(2);
|
|
||||||
const format = args.includes("--format")
|
|
||||||
? args[args.indexOf("--format") + 1]
|
|
||||||
: "markdown";
|
|
||||||
|
|
||||||
console.error("🔍 Extracting TRACES from codebase...");
|
|
||||||
const data = extractTraces();
|
|
||||||
|
|
||||||
if (format === "json") {
|
|
||||||
console.log(generateJson(data));
|
|
||||||
} else {
|
|
||||||
console.log(generateMarkdown(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
|
||||||
);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Find all files implementing a specific requirement
|
|
||||||
#
|
|
||||||
# Usage: ./find-req-implementations.sh UR-004
|
|
||||||
#
|
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
|
||||||
echo "Usage: $0 <REQUIREMENT_ID>"
|
|
||||||
echo "Example: $0 UR-004"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REQ_ID=$1
|
|
||||||
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " Implementations of $REQ_ID"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Full implementations
|
|
||||||
echo "Full Implementations:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
|
||||||
grep -v "@req-partial" | \
|
|
||||||
grep -v "@req-planned" | \
|
|
||||||
sed 's/src-tauri\/src\///' | \
|
|
||||||
sed 's/src\///' || echo " (none)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Partial implementations
|
|
||||||
echo "Partial Implementations:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
|
||||||
sed 's/src-tauri\/src\///' | \
|
|
||||||
sed 's/src\///' || echo " (none)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Planned
|
|
||||||
echo "Planned Implementations:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
|
||||||
sed 's/src-tauri\/src\///' | \
|
|
||||||
sed 's/src\///' || echo " (none)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
echo "Test Cases:"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
|
|
||||||
sed 's/src-tauri\/src\///' || echo " (none)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Generate traceability matrix in Markdown format
|
|
||||||
#
|
|
||||||
|
|
||||||
echo "# Requirements Traceability Matrix"
|
|
||||||
echo ""
|
|
||||||
echo "**Generated**: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo ""
|
|
||||||
echo "| Requirement | Files Implementing | Status | Notes |"
|
|
||||||
echo "|-------------|--------------------|--------|-------|"
|
|
||||||
|
|
||||||
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" README.md | sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | sort -u)
|
|
||||||
|
|
||||||
for req in $requirements; do
|
|
||||||
files=$(grep -rl "@req: $req" src-tauri/ src/ 2>/dev/null | \
|
|
||||||
sed 's|src-tauri/src/||; s|src/||' | \
|
|
||||||
paste -sd, -)
|
|
||||||
|
|
||||||
partial_files=$(grep -rl "@req-partial: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
|
||||||
planned=$(grep -rl "@req-planned: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
if [ -n "$files" ]; then
|
|
||||||
status="✅ Done"
|
|
||||||
notes=""
|
|
||||||
elif [ "$partial_files" -gt 0 ]; then
|
|
||||||
status="🔶 Partial"
|
|
||||||
notes="Platform-specific"
|
|
||||||
elif [ "$planned" -gt 0 ]; then
|
|
||||||
status="📋 Planned"
|
|
||||||
notes="Not implemented"
|
|
||||||
else
|
|
||||||
status="❌ Missing"
|
|
||||||
notes="No implementation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "| $req | ${files:-N/A} | $status | $notes |"
|
|
||||||
done
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# View Android logcat output filtered for the app
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
APP_PACKAGE="com.jellytau.app"
|
|
||||||
|
|
||||||
echo "📱 Showing logcat for $APP_PACKAGE"
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Filter logcat for the app's package name
|
|
||||||
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Sync Android source files from src-tauri/android to src-tauri/gen/android
|
|
||||||
# This ensures the generated build directory has the latest source files
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
||||||
SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/main/java/com/dtourolle/jellytau"
|
|
||||||
TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau"
|
|
||||||
|
|
||||||
echo "Syncing Android sources..."
|
|
||||||
echo " From: $SOURCE_DIR"
|
|
||||||
echo " To: $TARGET_DIR"
|
|
||||||
|
|
||||||
# Create target directory if it doesn't exist
|
|
||||||
mkdir -p "$TARGET_DIR"
|
|
||||||
|
|
||||||
# Remove old copies of player and security directories
|
|
||||||
rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
|
||||||
|
|
||||||
# Copy the directories
|
|
||||||
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
|
||||||
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
|
|
||||||
|
|
||||||
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
|
||||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
|
||||||
if [ -f "$kt_file" ]; then
|
|
||||||
cp "$kt_file" "$TARGET_DIR/"
|
|
||||||
echo " Copied: $(basename "$kt_file")"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Restore the app module build.gradle.kts (media3 deps + release signing config).
|
|
||||||
# gen/android is regenerated by `tauri android init`, so this tracked template is
|
|
||||||
# the source of truth and must be copied back after any (re)generation.
|
|
||||||
APP_GRADLE_SRC="$PROJECT_ROOT/src-tauri/android/app/build.gradle.kts"
|
|
||||||
APP_GRADLE_DST="$PROJECT_ROOT/src-tauri/gen/android/app/build.gradle.kts"
|
|
||||||
if [ -f "$APP_GRADLE_SRC" ]; then
|
|
||||||
cp "$APP_GRADLE_SRC" "$APP_GRADLE_DST"
|
|
||||||
echo " Copied: app/build.gradle.kts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Custom ProGuard/R8 keep rules. Required for minified release builds:
|
|
||||||
# the player/ and security/ Kotlin classes are loaded by name via JNI from
|
|
||||||
# Rust, so R8 can't see the references and would strip them without this.
|
|
||||||
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
|
|
||||||
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
|
|
||||||
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
|
|
||||||
if [ -f "$PROGUARD_SRC" ]; then
|
|
||||||
cp "$PROGUARD_SRC" "$PROGUARD_DST"
|
|
||||||
echo " Copied: app/proguard-jellytau.pro"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
|
|
||||||
# low-quality launcher icons from tauri.conf.json (which has no high-res
|
|
||||||
# Android source), so overwrite them with the real committed mipmaps.
|
|
||||||
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
|
|
||||||
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
|
|
||||||
if [ -d "$RES_SRC" ]; then
|
|
||||||
for dir in "$RES_SRC"/mipmap-*; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
name="$(basename "$dir")"
|
|
||||||
mkdir -p "$RES_DST/$name"
|
|
||||||
cp "$dir"/* "$RES_DST/$name/"
|
|
||||||
echo " Copied res: $name"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Run all tests (frontend and backend)
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🧪 Running all tests..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
|
||||||
bun run test
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🦀 Running Rust tests..."
|
|
||||||
cd src-tauri
|
|
||||||
cargo test
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ All tests passed!"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Run frontend tests only
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
|
||||||
bun run test "$@"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Run Rust tests only
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🦀 Running Rust tests..."
|
|
||||||
cd src-tauri
|
|
||||||
cargo test "$@"
|
|
||||||
cd ..
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
/target/
|
|
||||||
|
|
||||||
# Generated by Tauri
|
|
||||||
# Includes Android projects, schemas, and all other generated files
|
|
||||||
/gen/
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.ipa
|
|
||||||
|
|
||||||
# Android/Gradle (if not using gen/)
|
|
||||||
.gradle
|
|
||||||
local.properties
|
|
||||||
**/android/**/build/
|
|
||||||
**/android/.gradle/
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "jellytau"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "A Tauri App"
|
|
||||||
authors = ["you"]
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
# The `_lib` suffix may seem redundant but it is necessary
|
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
|
||||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
|
||||||
name = "jellytau_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
# Keep debug info minimal to reduce target/ size in CI (line numbers in
|
|
||||||
# backtraces are preserved; the bulky full debuginfo is dropped).
|
|
||||||
[profile.dev]
|
|
||||||
debug = "line-tables-only"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tauri = { version = "2", features = [] }
|
|
||||||
tauri-plugin-opener = "2"
|
|
||||||
tauri-plugin-os = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
uuid = { version = "1", features = ["v4"] }
|
|
||||||
rand = "0.8"
|
|
||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
|
||||||
tokio-util = "0.7"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
|
||||||
urlencoding = "2"
|
|
||||||
futures-util = "0.3"
|
|
||||||
async-trait = "0.1"
|
|
||||||
|
|
||||||
# SQLite for offline storage
|
|
||||||
tokio-rusqlite = "0.6"
|
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
directories = "5"
|
|
||||||
|
|
||||||
# Secure credential storage (system keyring with encrypted file fallback)
|
|
||||||
keyring = "3"
|
|
||||||
aes-gcm = "0.10"
|
|
||||||
base64 = "0.22"
|
|
||||||
sha2 = "0.10"
|
|
||||||
getrandom = "0.2"
|
|
||||||
log = "0.4"
|
|
||||||
env_logger = "0.11"
|
|
||||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
|
||||||
specta-typescript = "=0.0.9"
|
|
||||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
|
||||||
|
|
||||||
# Linux-specific dependencies
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
hostname = "0.4"
|
|
||||||
libc = "0.2"
|
|
||||||
# Use latest git version for better MPV version compatibility
|
|
||||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
|
||||||
|
|
||||||
# JNI for Android ExoPlayer integration
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
jni = "0.21"
|
|
||||||
ndk-context = "0.1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3.24.0"
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# ⚠️ IMPORTANT: Android Build File Locations
|
|
||||||
|
|
||||||
## Critical Information for Future Development
|
|
||||||
|
|
||||||
**DO NOT EDIT FILES IN `src-tauri/gen/android/` DIRECTLY!**
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
This project has **TWO** sets of Android source files:
|
|
||||||
|
|
||||||
1. **`src-tauri/android/`** - **SOURCE FILES** (edit these!)
|
|
||||||
- This is the template directory
|
|
||||||
- Changes here need to be copied to the generated directory
|
|
||||||
|
|
||||||
2. **`src-tauri/gen/android/`** - **GENERATED BUILD DIRECTORY** (do not edit directly!)
|
|
||||||
- This is where Gradle actually builds the APK
|
|
||||||
- Files here may be overwritten during builds
|
|
||||||
|
|
||||||
### How to Make Changes to Android Code
|
|
||||||
|
|
||||||
When you need to modify Android/Kotlin files:
|
|
||||||
|
|
||||||
1. **Edit the files in `src-tauri/android/src/main/java/`**
|
|
||||||
2. **Build using the provided script (which auto-syncs files)**
|
|
||||||
```bash
|
|
||||||
./scripts/build-android.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The build script automatically runs `./scripts/sync-android-sources.sh` which copies:
|
|
||||||
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/` → generated directory
|
|
||||||
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/security/` → generated directory
|
|
||||||
|
|
||||||
3. **Manual sync (if needed)**
|
|
||||||
```bash
|
|
||||||
./scripts/sync-android-sources.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why This Matters
|
|
||||||
|
|
||||||
- If you only edit `src-tauri/gen/android/`, your changes will be lost
|
|
||||||
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
|
||||||
- **You must edit both** (or edit source and copy to generated)
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
Player-related Kotlin files:
|
|
||||||
- `player/JellyTauPlayer.kt` - Main player implementation
|
|
||||||
- `player/JellyTauPlaybackService.kt` - MediaSession service for lockscreen controls
|
|
||||||
- `security/SecureStorage.kt` - Android Keystore integration for secure credential storage
|
|
||||||
|
|
||||||
Always check BOTH locations exist and match after making changes!
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("rust")
|
|
||||||
}
|
|
||||||
|
|
||||||
val tauriProperties = Properties().apply {
|
|
||||||
val propFile = file("tauri.properties")
|
|
||||||
if (propFile.exists()) {
|
|
||||||
propFile.inputStream().use { load(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release signing: loaded from gen/android/keystore.properties if present.
|
|
||||||
// Falls back to no signing config (debug-signed) when the file is absent.
|
|
||||||
val keystoreProperties = Properties().apply {
|
|
||||||
val propFile = rootProject.file("keystore.properties")
|
|
||||||
if (propFile.exists()) {
|
|
||||||
propFile.inputStream().use { load(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk = 36
|
|
||||||
namespace = "com.dtourolle.jellytau"
|
|
||||||
defaultConfig {
|
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
|
||||||
applicationId = "com.dtourolle.jellytau"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
|
||||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
|
||||||
}
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
keystoreProperties.getProperty("storeFile")?.let {
|
|
||||||
storeFile = file(it)
|
|
||||||
storePassword = keystoreProperties.getProperty("storePassword")
|
|
||||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
|
||||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("debug") {
|
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
|
||||||
isDebuggable = true
|
|
||||||
isJniDebuggable = true
|
|
||||||
isMinifyEnabled = false
|
|
||||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
|
||||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getByName("release") {
|
|
||||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
isMinifyEnabled = true
|
|
||||||
proguardFiles(
|
|
||||||
*fileTree(".") { include("**/*.pro") }
|
|
||||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
.toList().toTypedArray()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rust {
|
|
||||||
rootDirRel = "../../../"
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.webkit:webkit:1.14.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
|
||||||
|
|
||||||
// Media3 dependencies for audio playback
|
|
||||||
implementation("androidx.media3:media3-exoplayer:1.5.0")
|
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
|
|
||||||
implementation("androidx.media3:media3-session:1.5.0")
|
|
||||||
implementation("androidx.media3:media3-common:1.5.0")
|
|
||||||
implementation("com.google.guava:guava:33.0.0-android")
|
|
||||||
|
|
||||||
// Media library for VolumeProviderCompat (remote volume control)
|
|
||||||
implementation("androidx.media:media:1.7.0")
|
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# JellyTau custom keep rules.
|
|
||||||
#
|
|
||||||
# These classes are loaded by name from the Rust backend via JNI
|
|
||||||
# (env.find_class / class-loader lookups), so R8 cannot see the
|
|
||||||
# references and would otherwise strip or rename them in a minified
|
|
||||||
# release build — causing an instant ClassNotFoundException crash on
|
|
||||||
# startup. See src-tauri/src/player/android/mod.rs and
|
|
||||||
# src-tauri/src/credentials.rs.
|
|
||||||
-keep class com.dtourolle.jellytau.player.** { *; }
|
|
||||||
-keep class com.dtourolle.jellytau.security.** { *; }
|
|
||||||
|
|
||||||
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
|
|
||||||
-keep class androidx.media3.** { *; }
|
|
||||||
-dontwarn androidx.media3.**
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
package com.dtourolle.jellytau.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JellyTau media player wrapper using ExoPlayer (Media3).
|
|
||||||
*
|
|
||||||
* This class is designed to be called from Rust via JNI.
|
|
||||||
* All player operations are marshalled to the main thread.
|
|
||||||
*/
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class JellyTauPlayer(context: Context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Position update interval in milliseconds */
|
|
||||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
|
||||||
|
|
||||||
/** Singleton instance for JNI access */
|
|
||||||
@Volatile
|
|
||||||
private var instance: JellyTauPlayer? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Load the native library for JNI callbacks
|
|
||||||
System.loadLibrary("jellytau_lib")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the player singleton.
|
|
||||||
* Called from Rust via JNI during Android startup.
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun initialize(context: Context) {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized(this) {
|
|
||||||
if (instance == null) {
|
|
||||||
instance = JellyTauPlayer(context.applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the singleton instance.
|
|
||||||
* @throws IllegalStateException if not initialized
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(): JellyTauPlayer {
|
|
||||||
return instance ?: throw IllegalStateException("JellyTauPlayer not initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
private val exoPlayer: ExoPlayer
|
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
||||||
private var positionUpdateJob: Job? = null
|
|
||||||
|
|
||||||
/** Current media ID being played */
|
|
||||||
private var currentMediaId: String? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Create ExoPlayer on main thread
|
|
||||||
exoPlayer = ExoPlayer.Builder(context).build()
|
|
||||||
|
|
||||||
// Set up player listener
|
|
||||||
exoPlayer.addListener(object : Player.Listener {
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
when (playbackState) {
|
|
||||||
Player.STATE_READY -> {
|
|
||||||
// Media loaded and ready
|
|
||||||
val duration = exoPlayer.duration / 1000.0
|
|
||||||
nativeOnMediaLoaded(duration)
|
|
||||||
|
|
||||||
val state = if (exoPlayer.isPlaying) "playing" else "paused"
|
|
||||||
nativeOnStateChanged(state, currentMediaId)
|
|
||||||
}
|
|
||||||
Player.STATE_ENDED -> {
|
|
||||||
// Playback completed
|
|
||||||
stopPositionUpdates()
|
|
||||||
nativeOnPlaybackEnded()
|
|
||||||
}
|
|
||||||
Player.STATE_BUFFERING -> {
|
|
||||||
nativeOnBuffering(0)
|
|
||||||
}
|
|
||||||
Player.STATE_IDLE -> {
|
|
||||||
// Player is idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
val state = if (isPlaying) "playing" else "paused"
|
|
||||||
nativeOnStateChanged(state, currentMediaId)
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
startPositionUpdates()
|
|
||||||
} else {
|
|
||||||
stopPositionUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
val message = error.message ?: "Unknown playback error"
|
|
||||||
val recoverable = error.errorCode != PlaybackException.ERROR_CODE_UNSPECIFIED
|
|
||||||
nativeOnError(message, recoverable)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load media from a URL.
|
|
||||||
* @param url The media URL to load
|
|
||||||
* @param mediaId The unique ID for this media item
|
|
||||||
*/
|
|
||||||
fun load(url: String, mediaId: String) {
|
|
||||||
mainHandler.post {
|
|
||||||
currentMediaId = mediaId
|
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
|
||||||
exoPlayer.prepare()
|
|
||||||
exoPlayer.playWhenReady = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start or resume playback.
|
|
||||||
*/
|
|
||||||
fun play() {
|
|
||||||
mainHandler.post {
|
|
||||||
exoPlayer.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause playback.
|
|
||||||
*/
|
|
||||||
fun pause() {
|
|
||||||
mainHandler.post {
|
|
||||||
exoPlayer.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback and release media.
|
|
||||||
*/
|
|
||||||
fun stop() {
|
|
||||||
mainHandler.post {
|
|
||||||
stopPositionUpdates()
|
|
||||||
exoPlayer.stop()
|
|
||||||
exoPlayer.clearMediaItems()
|
|
||||||
currentMediaId = null
|
|
||||||
nativeOnStateChanged("idle", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek to a position.
|
|
||||||
* @param positionSeconds Position in seconds
|
|
||||||
*/
|
|
||||||
fun seek(positionSeconds: Double) {
|
|
||||||
mainHandler.post {
|
|
||||||
val positionMs = (positionSeconds * 1000).toLong()
|
|
||||||
exoPlayer.seekTo(positionMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the volume.
|
|
||||||
* @param volume Volume level from 0.0 to 1.0
|
|
||||||
*/
|
|
||||||
fun setVolume(volume: Float) {
|
|
||||||
mainHandler.post {
|
|
||||||
exoPlayer.volume = volume.coerceIn(0f, 1f)
|
|
||||||
nativeOnVolumeChanged(exoPlayer.volume, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current playback position in seconds.
|
|
||||||
*/
|
|
||||||
fun getPosition(): Double {
|
|
||||||
return exoPlayer.currentPosition / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total duration in seconds.
|
|
||||||
*/
|
|
||||||
fun getDuration(): Double {
|
|
||||||
val duration = exoPlayer.duration
|
|
||||||
return if (duration > 0) duration / 1000.0 else 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current volume.
|
|
||||||
*/
|
|
||||||
fun getVolume(): Float {
|
|
||||||
return exoPlayer.volume
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if media is currently loaded.
|
|
||||||
*/
|
|
||||||
fun isLoaded(): Boolean {
|
|
||||||
return exoPlayer.playbackState == Player.STATE_READY ||
|
|
||||||
exoPlayer.playbackState == Player.STATE_BUFFERING
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release player resources.
|
|
||||||
* Call when the app is closing.
|
|
||||||
*/
|
|
||||||
fun release() {
|
|
||||||
mainHandler.post {
|
|
||||||
stopPositionUpdates()
|
|
||||||
coroutineScope.cancel()
|
|
||||||
exoPlayer.release()
|
|
||||||
instance = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPositionUpdates() {
|
|
||||||
positionUpdateJob?.cancel()
|
|
||||||
positionUpdateJob = coroutineScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
if (exoPlayer.isPlaying) {
|
|
||||||
val position = exoPlayer.currentPosition / 1000.0
|
|
||||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
|
||||||
nativeOnPositionUpdate(position, duration)
|
|
||||||
}
|
|
||||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopPositionUpdates() {
|
|
||||||
positionUpdateJob?.cancel()
|
|
||||||
positionUpdateJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native methods to call back to Rust via JNI
|
|
||||||
// These will be implemented in the Rust android module
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when position updates during playback.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when player state changes.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when media has finished loading.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnMediaLoaded(duration: Double)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when playback reaches the end.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnPlaybackEnded()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when buffering state changes.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnBuffering(percent: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a playback error occurs.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnError(message: String, recoverable: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when volume changes.
|
|
||||||
*/
|
|
||||||
private external fun nativeOnVolumeChanged(volume: Float, muted: Boolean)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.library")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.dtourolle.jellytau.player"
|
|
||||||
compileSdk = 36
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdk = 24
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
getByName("debug") {
|
|
||||||
}
|
|
||||||
getByName("release") {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
|
||||||
implementation("androidx.media3:media3-common:1.5.1")
|
|
||||||
implementation("androidx.media3:media3-session:1.5.1")
|
|
||||||
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- Enable hardware acceleration for video playback performance -->
|
|
||||||
<application android:hardwareAccelerated="true" />
|
|
||||||
</manifest>
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package com.dtourolle.jellytau
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.webkit.JavascriptInterface
|
|
||||||
import android.webkit.WebChromeClient
|
|
||||||
import android.webkit.WebSettings
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
|
||||||
private var configAttempts = 0
|
|
||||||
private val maxConfigAttempts = 10
|
|
||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
|
||||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// Configure WebView for media playback after Tauri initialization
|
|
||||||
handler.postDelayed({
|
|
||||||
configureWebViewForMedia()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
configureWebViewForMedia()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureWebViewForMedia() {
|
|
||||||
try {
|
|
||||||
val webView = findWebView(window.decorView)
|
|
||||||
|
|
||||||
if (webView == null) {
|
|
||||||
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
|
||||||
if (configAttempts < maxConfigAttempts) {
|
|
||||||
configAttempts++
|
|
||||||
handler.postDelayed({
|
|
||||||
configureWebViewForMedia()
|
|
||||||
}, 200)
|
|
||||||
} else {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to find WebView after $maxConfigAttempts attempts")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
|
||||||
webView.addJavascriptInterface(object : Any() {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun requestAudioFocus() {
|
|
||||||
handler.post { this@MainActivity.requestAudioFocus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun abandonAudioFocus() {
|
|
||||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
|
||||||
}
|
|
||||||
}, "AndroidAudioFocus")
|
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
|
||||||
|
|
||||||
// Set WebChromeClient to handle video playback and audio focus
|
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
|
||||||
super.onShowCustomView(view, callback)
|
|
||||||
android.util.Log.d("MainActivity", "Video entered fullscreen")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHideCustomView() {
|
|
||||||
super.onHideCustomView()
|
|
||||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
|
||||||
|
|
||||||
webView.settings.apply {
|
|
||||||
// CRITICAL: Enable media playback without user gesture requirement
|
|
||||||
mediaPlaybackRequiresUserGesture = false
|
|
||||||
android.util.Log.d("MainActivity", "Set mediaPlaybackRequiresUserGesture = false")
|
|
||||||
|
|
||||||
javaScriptEnabled = true
|
|
||||||
domStorageEnabled = true
|
|
||||||
allowFileAccess = true
|
|
||||||
allowContentAccess = true
|
|
||||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute JavaScript to ensure any video elements are unmuted and request audio focus
|
|
||||||
webView.post {
|
|
||||||
webView.evaluateJavascript("""
|
|
||||||
(function() {
|
|
||||||
console.log('[Android] Ensuring video elements are unmuted');
|
|
||||||
const videos = document.getElementsByTagName('video');
|
|
||||||
for (let video of videos) {
|
|
||||||
video.muted = false;
|
|
||||||
video.volume = 1.0;
|
|
||||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
|
||||||
|
|
||||||
// Add event listeners to manage audio focus
|
|
||||||
video.addEventListener('play', function() {
|
|
||||||
console.log('[Android] Video play event - requesting audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.requestAudioFocus();
|
|
||||||
}
|
|
||||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('pause', function() {
|
|
||||||
console.log('[Android] Video pause event - abandoning audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('ended', function() {
|
|
||||||
console.log('[Android] Video ended event - abandoning audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('volumechange', function() {
|
|
||||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor for new video elements
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const videos = document.getElementsByTagName('video');
|
|
||||||
for (let video of videos) {
|
|
||||||
if (video.muted) {
|
|
||||||
video.muted = false;
|
|
||||||
video.volume = 1.0;
|
|
||||||
console.log('[Android] New video found and unmuted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
console.log('[Android] Video unmute observer installed');
|
|
||||||
})();
|
|
||||||
""".trimIndent(), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findWebView(view: android.view.View): WebView? {
|
|
||||||
if (view is WebView) {
|
|
||||||
android.util.Log.d("MainActivity", "Found WebView!")
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view is android.view.ViewGroup) {
|
|
||||||
for (i in 0 until view.childCount) {
|
|
||||||
val child = view.getChildAt(i)
|
|
||||||
val webView = findWebView(child)
|
|
||||||
if (webView != null) {
|
|
||||||
return webView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
||||||
.setAudioAttributes(audioAttributes)
|
|
||||||
.setAcceptsDelayedFocusGain(true)
|
|
||||||
.setOnAudioFocusChangeListener { focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val result = audioManager.requestAudioFocus(
|
|
||||||
{ focusChange ->
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
|
||||||
},
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
AudioManager.AUDIOFOCUS_GAIN
|
|
||||||
)
|
|
||||||
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun abandonAudioFocus() {
|
|
||||||
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
audioFocusRequest?.let {
|
|
||||||
audioManager.abandonAudioFocusRequest(it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
audioManager.abandonAudioFocus { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package com.dtourolle.jellytau
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
|
|
||||||
*
|
|
||||||
* This class handles attaching and detaching the native ExoPlayer SurfaceView
|
|
||||||
* so that it renders video content behind the WebView.
|
|
||||||
*/
|
|
||||||
object VideoOverlayManager {
|
|
||||||
|
|
||||||
private var attachedSurfaceView: SurfaceView? = null
|
|
||||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
|
||||||
private var listenerContentView: ViewGroup? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach the video SurfaceView to the Activity's content view.
|
|
||||||
*
|
|
||||||
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
|
||||||
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
|
||||||
*
|
|
||||||
* @param activity The Activity to attach the surface to
|
|
||||||
*/
|
|
||||||
fun attachVideoSurface(activity: Activity) {
|
|
||||||
try {
|
|
||||||
// Get the SurfaceView from JellyTauPlayer
|
|
||||||
val player = JellyTauPlayer.getInstance()
|
|
||||||
val surfaceView = player.getSurfaceView()
|
|
||||||
|
|
||||||
if (surfaceView == null) {
|
|
||||||
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the root content view
|
|
||||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
|
||||||
|
|
||||||
// Remove from parent if already attached elsewhere
|
|
||||||
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
|
||||||
|
|
||||||
// Configure layout params to fill the screen
|
|
||||||
val layoutParams = FrameLayout.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add to content view at index 0 (behind WebView)
|
|
||||||
contentView.addView(surfaceView, 0, layoutParams)
|
|
||||||
attachedSurfaceView = surfaceView
|
|
||||||
|
|
||||||
// Re-fit the video whenever the content view's bounds change (e.g. on
|
|
||||||
// device rotation) so the video is letterboxed to fit instead of being
|
|
||||||
// stretched/cropped by the MATCH_PARENT surface.
|
|
||||||
removeLayoutListener()
|
|
||||||
val listener = android.view.View.OnLayoutChangeListener {
|
|
||||||
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
|
||||||
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
|
|
||||||
player.fitSurfaceToScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contentView.addOnLayoutChangeListener(listener)
|
|
||||||
contentLayoutListener = listener
|
|
||||||
listenerContentView = contentView
|
|
||||||
|
|
||||||
// Fit once now that the surface is attached and the parent is sized.
|
|
||||||
player.fitSurfaceToScreen()
|
|
||||||
|
|
||||||
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detach the video SurfaceView from the Activity's view hierarchy.
|
|
||||||
*
|
|
||||||
* @param activity The Activity to detach the surface from
|
|
||||||
*/
|
|
||||||
fun detachVideoSurface(activity: Activity) {
|
|
||||||
try {
|
|
||||||
removeLayoutListener()
|
|
||||||
attachedSurfaceView?.let { surfaceView ->
|
|
||||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
|
||||||
contentView.removeView(surfaceView)
|
|
||||||
attachedSurfaceView = null
|
|
||||||
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a video surface is currently attached.
|
|
||||||
*
|
|
||||||
* @return true if a surface is attached, false otherwise
|
|
||||||
*/
|
|
||||||
fun isVideoSurfaceAttached(): Boolean {
|
|
||||||
return attachedSurfaceView != null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeLayoutListener() {
|
|
||||||
contentLayoutListener?.let { listener ->
|
|
||||||
listenerContentView?.removeOnLayoutChangeListener(listener)
|
|
||||||
}
|
|
||||||
contentLayoutListener = null
|
|
||||||
listenerContentView = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package com.dtourolle.jellytau.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.LruCache
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory cache for album artwork bitmaps with LRU eviction.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
|
|
||||||
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
|
|
||||||
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
|
|
||||||
* - Graceful error handling for network failures and corrupted images
|
|
||||||
* - Singleton pattern for app-wide access
|
|
||||||
*
|
|
||||||
* Cache lifecycle: In-memory only, cleared on app termination.
|
|
||||||
*/
|
|
||||||
class AlbumArtCache(context: Context) {
|
|
||||||
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
|
||||||
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
|
|
||||||
|
|
||||||
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
|
||||||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
|
||||||
return bitmap.byteCount / 1024 // Size in KB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get artwork bitmap for the given URL.
|
|
||||||
*
|
|
||||||
* Checks memory cache first, then downloads from network if not cached.
|
|
||||||
* Scaling and error handling are done transparently.
|
|
||||||
*
|
|
||||||
* @param url The Jellyfin server artwork URL
|
|
||||||
* @return The bitmap, or null if download failed or URL is invalid
|
|
||||||
*/
|
|
||||||
suspend fun getArtwork(url: String): Bitmap? {
|
|
||||||
// Check memory cache first
|
|
||||||
memoryCache.get(url)?.let { return it }
|
|
||||||
|
|
||||||
// Download from network if not cached
|
|
||||||
return downloadAndCache(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download artwork from network and add to cache.
|
|
||||||
*
|
|
||||||
* Runs on IO dispatcher to avoid blocking the main thread.
|
|
||||||
* Automatically scales large images to 512x512 max.
|
|
||||||
* On failure, logs error and returns null.
|
|
||||||
*
|
|
||||||
* @param url The Jellyfin server artwork URL
|
|
||||||
* @return The cached bitmap, or null if download/decode failed
|
|
||||||
*/
|
|
||||||
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
|
||||||
connection.doInput = true
|
|
||||||
connection.connectTimeout = 5000
|
|
||||||
connection.readTimeout = 5000
|
|
||||||
connection.connect()
|
|
||||||
|
|
||||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
|
||||||
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
|
|
||||||
val input = connection.inputStream
|
|
||||||
val bitmap = BitmapFactory.decodeStream(input)
|
|
||||||
input.close()
|
|
||||||
connection.disconnect()
|
|
||||||
|
|
||||||
bitmap?.let {
|
|
||||||
// Scale down if too large (lock screen doesn't need full resolution)
|
|
||||||
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
|
|
||||||
memoryCache.put(url, scaled)
|
|
||||||
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
|
|
||||||
scaled
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
|
|
||||||
*
|
|
||||||
* @param bitmap The original bitmap
|
|
||||||
* @param maxSize Maximum width or height (e.g., 512)
|
|
||||||
* @return Original bitmap if smaller than maxSize, else scaled version
|
|
||||||
*/
|
|
||||||
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
|
|
||||||
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
|
|
||||||
|
|
||||||
val ratio = minOf(
|
|
||||||
maxSize.toFloat() / bitmap.width,
|
|
||||||
maxSize.toFloat() / bitmap.height
|
|
||||||
)
|
|
||||||
|
|
||||||
val newWidth = (bitmap.width * ratio).toInt()
|
|
||||||
val newHeight = (bitmap.height * ratio).toInt()
|
|
||||||
|
|
||||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cached bitmaps from memory.
|
|
||||||
*
|
|
||||||
* Useful for memory-constrained situations or settings reset.
|
|
||||||
*/
|
|
||||||
fun clear() {
|
|
||||||
memoryCache.evictAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var instance: AlbumArtCache? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create singleton instance.
|
|
||||||
*
|
|
||||||
* Thread-safe with double-checked locking pattern.
|
|
||||||
*
|
|
||||||
* @param context Android context for initialization
|
|
||||||
* @return The singleton AlbumArtCache instance
|
|
||||||
*/
|
|
||||||
fun getInstance(context: Context): AlbumArtCache {
|
|
||||||
return instance ?: synchronized(this) {
|
|
||||||
instance ?: AlbumArtCache(context).also { instance = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package com.dtourolle.jellytau.player
|
|
||||||
|
|
||||||
import android.media.MediaCodecList
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects hardware codec capabilities using MediaCodecList.
|
|
||||||
*
|
|
||||||
* This class queries the device's media codec capabilities and reports
|
|
||||||
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
|
||||||
*/
|
|
||||||
object CodecDetector {
|
|
||||||
private const val TAG = "CodecDetector"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class to hold detected codec capabilities.
|
|
||||||
*/
|
|
||||||
data class CodecCapabilities(
|
|
||||||
val videoCodecs: List<String>,
|
|
||||||
val audioCodecs: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect all hardware decoders available on this device.
|
|
||||||
*
|
|
||||||
* Uses Android's MediaCodecList API to query supported MIME types
|
|
||||||
* and maps them to Jellyfin codec names.
|
|
||||||
*
|
|
||||||
* @return CodecCapabilities containing lists of supported video and audio codecs
|
|
||||||
*/
|
|
||||||
fun detectHardwareCodecs(): CodecCapabilities {
|
|
||||||
val videoCodecs = mutableSetOf<String>()
|
|
||||||
val audioCodecs = mutableSetOf<String>()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all codec infos (including both hardware and software codecs)
|
|
||||||
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
|
||||||
|
|
||||||
for (codecInfo in codecList.codecInfos) {
|
|
||||||
// Only interested in decoders (not encoders)
|
|
||||||
if (codecInfo.isEncoder) continue
|
|
||||||
|
|
||||||
// Check if it's a hardware codec
|
|
||||||
val isHardware = !codecInfo.isSoftwareOnly
|
|
||||||
|
|
||||||
for (type in codecInfo.supportedTypes) {
|
|
||||||
when {
|
|
||||||
type.startsWith("video/") -> {
|
|
||||||
val codec = mapMimeTypeToCodecName(type, isVideo = true)
|
|
||||||
if (codec != null) {
|
|
||||||
videoCodecs.add(codec)
|
|
||||||
Log.d(TAG, "Video codec: $codec (MIME: $type, Hardware: $isHardware)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type.startsWith("audio/") -> {
|
|
||||||
val codec = mapMimeTypeToCodecName(type, isVideo = false)
|
|
||||||
if (codec != null) {
|
|
||||||
audioCodecs.add(codec)
|
|
||||||
Log.d(TAG, "Audio codec: $codec (MIME: $type, Hardware: $isHardware)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
|
||||||
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error detecting codecs", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return CodecCapabilities(
|
|
||||||
videoCodecs = videoCodecs.sorted(),
|
|
||||||
audioCodecs = audioCodecs.sorted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map Android MIME types to Jellyfin codec names.
|
|
||||||
*
|
|
||||||
* Based on Jellyfin's codec naming conventions and Android's
|
|
||||||
* supported MIME type constants.
|
|
||||||
*
|
|
||||||
* @param mimeType Android MIME type (e.g., "video/avc", "audio/mp4a-latm")
|
|
||||||
* @param isVideo Whether this is a video codec
|
|
||||||
* @return Jellyfin codec name or null if unknown
|
|
||||||
*/
|
|
||||||
private fun mapMimeTypeToCodecName(mimeType: String, isVideo: Boolean): String? {
|
|
||||||
return when (mimeType) {
|
|
||||||
// Video codecs
|
|
||||||
"video/avc" -> "h264"
|
|
||||||
"video/hevc" -> "hevc"
|
|
||||||
"video/x-vnd.on2.vp8" -> "vp8"
|
|
||||||
"video/x-vnd.on2.vp9" -> "vp9"
|
|
||||||
"video/av01" -> "av1"
|
|
||||||
"video/mp4v-es" -> "mpeg4"
|
|
||||||
"video/3gpp" -> "h263"
|
|
||||||
"video/mpeg2" -> "mpeg2video"
|
|
||||||
"video/divx" -> "divx"
|
|
||||||
"video/xvid" -> "xvid"
|
|
||||||
"video/x-ms-wmv" -> "wmv"
|
|
||||||
"video/vc1" -> "vc1"
|
|
||||||
|
|
||||||
// Audio codecs
|
|
||||||
"audio/mp4a-latm" -> "aac"
|
|
||||||
"audio/mpeg" -> "mp3"
|
|
||||||
"audio/mpeg-L1" -> "mp1"
|
|
||||||
"audio/mpeg-L2" -> "mp2"
|
|
||||||
"audio/opus" -> "opus"
|
|
||||||
"audio/vorbis" -> "vorbis"
|
|
||||||
"audio/flac" -> "flac"
|
|
||||||
"audio/alac" -> "alac"
|
|
||||||
"audio/ac3" -> "ac3"
|
|
||||||
"audio/eac3" -> "eac3"
|
|
||||||
"audio/eac3-joc" -> "eac3"
|
|
||||||
"audio/dts" -> "dts"
|
|
||||||
"audio/vnd.dts.hd" -> "dts"
|
|
||||||
"audio/x-ms-wma" -> "wma"
|
|
||||||
"audio/amr-nb" -> "amrnb"
|
|
||||||
"audio/amr-wb" -> "amrwb"
|
|
||||||
"audio/3gpp" -> "amrnb"
|
|
||||||
"audio/raw" -> "pcm"
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Log.d(TAG, "Unknown MIME type: $mimeType")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
package com.dtourolle.jellytau.player
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.media.VolumeProviderCompat
|
|
||||||
import androidx.media3.common.ForwardingPlayer
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.session.MediaSession
|
|
||||||
import androidx.media3.session.MediaSessionService
|
|
||||||
import com.google.common.util.concurrent.Futures
|
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MediaSessionService for lockscreen controls and media notifications.
|
|
||||||
*
|
|
||||||
* This service creates a MediaSession that integrates with the system's
|
|
||||||
* media controls (lockscreen, notification shade, Bluetooth devices).
|
|
||||||
*
|
|
||||||
* Media commands are routed back to Rust via JNI to ensure proper
|
|
||||||
* queue management for next/previous track operations.
|
|
||||||
*/
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class JellyTauPlaybackService : MediaSessionService() {
|
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
|
||||||
private var mediaSessionCompat: MediaSessionCompat? = null
|
|
||||||
private var wrappedPlayer: androidx.media3.common.ForwardingPlayer? = null
|
|
||||||
private var volumeProvider: VolumeProviderCompat? = null
|
|
||||||
private var isRemoteVolumeEnabled = false
|
|
||||||
private var remoteVolumeLevel = 50 // 0-100
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "playback_channel"
|
|
||||||
private const val NOTIFICATION_CHANNEL_NAME = "Playback"
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var instance: JellyTauPlaybackService? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the service instance if running.
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(): JellyTauPlaybackService? = instance
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Ensure native library is loaded for JNI callbacks
|
|
||||||
System.loadLibrary("jellytau_lib")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
instance = this
|
|
||||||
|
|
||||||
// Create notification channel for Android O+
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
NOTIFICATION_CHANNEL_ID,
|
|
||||||
NOTIFICATION_CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Media playback controls"
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if JellyTauPlayer is initialized
|
|
||||||
if (!JellyTauPlayer.isInitialized()) {
|
|
||||||
android.util.Log.w("JellyTauPlaybackService", "JellyTauPlayer not initialized, initializing now")
|
|
||||||
// Initialize the player with application context
|
|
||||||
JellyTauPlayer.initialize(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the existing JellyTauPlayer instance with its ExoPlayer
|
|
||||||
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
|
||||||
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
|
||||||
|
|
||||||
// Wrap the ExoPlayer to intercept commands from Media3 controllers
|
|
||||||
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
|
|
||||||
// session rather than the MediaSessionCompat).
|
|
||||||
//
|
|
||||||
// We do NOT execute on ExoPlayer directly here. Every transport command
|
|
||||||
// is routed to Rust via nativeOnMediaCommand, which is the single decision
|
|
||||||
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
|
|
||||||
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
|
|
||||||
// would double-handle local commands and incorrectly drive the local
|
|
||||||
// player while casting.
|
|
||||||
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
|
||||||
override fun play() {
|
|
||||||
nativeOnMediaCommand("play")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pause() {
|
|
||||||
nativeOnMediaCommand("pause")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekToNext() {
|
|
||||||
nativeOnMediaCommand("next")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekToPrevious() {
|
|
||||||
nativeOnMediaCommand("previous")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
|
||||||
val positionSeconds = positionMs / 1000.0
|
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
nativeOnMediaCommand("stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create MediaSession with the wrapped player and callback for command handling
|
|
||||||
mediaSession = MediaSession.Builder(this, wrappedPlayer!!)
|
|
||||||
.setCallback(object : MediaSession.Callback {
|
|
||||||
override fun onSetMediaItems(
|
|
||||||
mediaSession: MediaSession,
|
|
||||||
controller: MediaSession.ControllerInfo,
|
|
||||||
mediaItems: MutableList<androidx.media3.common.MediaItem>,
|
|
||||||
startIndex: Int,
|
|
||||||
startPositionMs: Long
|
|
||||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
|
||||||
return Futures.immediateFuture(
|
|
||||||
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// Create MediaSessionCompat for volume control and lock screen button handling
|
|
||||||
// We need this alongside Media3's MediaSession because MediaSessionCompat provides
|
|
||||||
// VolumeProviderCompat support for remote volume control (routing hardware button presses)
|
|
||||||
mediaSessionCompat = MediaSessionCompat(this, "JellyTauMediaSession").apply {
|
|
||||||
setFlags(
|
|
||||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
|
||||||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
|
||||||
)
|
|
||||||
isActive = true
|
|
||||||
|
|
||||||
// Set callback to handle lock screen button presses.
|
|
||||||
//
|
|
||||||
// All transport commands are routed through Rust via nativeOnMediaCommand
|
|
||||||
// rather than directly to ExoPlayer. Rust is the single decision point:
|
|
||||||
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
|
|
||||||
// the command to the remote Jellyfin session. This keeps the lockscreen
|
|
||||||
// working identically for both, and avoids the ExoPlayer-only behaviour
|
|
||||||
// that left remote playback uncontrollable from the lockscreen.
|
|
||||||
setCallback(object : MediaSessionCompat.Callback() {
|
|
||||||
override fun onPlay() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
|
||||||
nativeOnMediaCommand("play")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
|
||||||
nativeOnMediaCommand("pause")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
|
||||||
nativeOnMediaCommand("next")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
|
||||||
nativeOnMediaCommand("previous")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
|
||||||
nativeOnMediaCommand("stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
|
||||||
val positionSeconds = position / 1000.0
|
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
// Start as foreground service immediately to avoid crash
|
|
||||||
// Media3 will replace this with its own notification
|
|
||||||
val notification = createBasicNotification()
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createBasicNotification(): Notification {
|
|
||||||
// Create a media-style notification with lockscreen controls
|
|
||||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
||||||
.setContentTitle("JellyTau")
|
|
||||||
.setContentText("Playing")
|
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setStyle(
|
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
|
||||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
|
||||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
android.R.drawable.ic_media_previous,
|
|
||||||
"Previous",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
android.R.drawable.ic_media_pause,
|
|
||||||
"Pause",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
PlaybackStateCompat.ACTION_PAUSE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
android.R.drawable.ic_media_next,
|
|
||||||
"Next",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last-known metadata/state, retained so lightweight position ticks can
|
|
||||||
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
|
|
||||||
// and notification. Kept in sync by updateMediaMetadata().
|
|
||||||
private var lastTitle: String = ""
|
|
||||||
private var lastArtist: String = ""
|
|
||||||
private var lastIsPlaying: Boolean = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the MediaSession metadata and playback state, plus the notification.
|
|
||||||
*
|
|
||||||
* Call this when the track or play/pause state changes. For frequent position
|
|
||||||
* updates during playback, use [updatePlaybackPosition] instead, which is much
|
|
||||||
* cheaper (no metadata rebuild, no notification rebuild).
|
|
||||||
*/
|
|
||||||
fun updateMediaMetadata(
|
|
||||||
title: String,
|
|
||||||
artist: String,
|
|
||||||
album: String?,
|
|
||||||
duration: Long,
|
|
||||||
position: Long,
|
|
||||||
isPlaying: Boolean
|
|
||||||
) {
|
|
||||||
val session = mediaSessionCompat ?: return
|
|
||||||
|
|
||||||
lastTitle = title
|
|
||||||
lastArtist = artist
|
|
||||||
lastIsPlaying = isPlaying
|
|
||||||
|
|
||||||
// Update MediaSession metadata
|
|
||||||
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
|
||||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
|
||||||
.putLong(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION, duration)
|
|
||||||
|
|
||||||
album?.let {
|
|
||||||
metadataBuilder.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM, it)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.setMetadata(metadataBuilder.build())
|
|
||||||
|
|
||||||
// Update MediaSession playback state
|
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
|
||||||
|
|
||||||
// Update the notification
|
|
||||||
updateNotification(title, artist, isPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update only the playback position (and play/pause state) on the MediaSession.
|
|
||||||
*
|
|
||||||
* This is the cheap path used for the periodic (250ms) position ticks: it
|
|
||||||
* refreshes the lockscreen scrubber without rebuilding metadata or the
|
|
||||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
|
||||||
* from the last play/pause and drifts out of sync with actual playback.
|
|
||||||
*
|
|
||||||
* @param position Position in milliseconds
|
|
||||||
* @param isPlaying Whether playback is currently active
|
|
||||||
*/
|
|
||||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
|
||||||
val session = mediaSessionCompat ?: return
|
|
||||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
|
||||||
lastIsPlaying = isPlaying
|
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
|
||||||
// Only rebuild the notification when the play/pause icon actually flips.
|
|
||||||
if (notificationStateChanged) {
|
|
||||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a PlaybackStateCompat with the standard transport actions.
|
|
||||||
*
|
|
||||||
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
|
||||||
* Android does not extrapolate the position past a paused track.
|
|
||||||
*/
|
|
||||||
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
|
||||||
return PlaybackStateCompat.Builder()
|
|
||||||
.setActions(
|
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
|
||||||
PlaybackStateCompat.ACTION_STOP or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
|
||||||
PlaybackStateCompat.ACTION_SEEK_TO
|
|
||||||
)
|
|
||||||
.setState(
|
|
||||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
|
||||||
position,
|
|
||||||
if (isPlaying) 1.0f else 0.0f
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the notification with current media metadata and playback state.
|
|
||||||
* This should be called whenever metadata or playback state changes.
|
|
||||||
*/
|
|
||||||
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
|
||||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(artist)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setStyle(
|
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
|
||||||
.setMediaSession(mediaSessionCompat?.sessionToken)
|
|
||||||
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
android.R.drawable.ic_media_previous,
|
|
||||||
"Previous",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
|
|
||||||
if (isPlaying) "Pause" else "Play",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
if (isPlaying) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addAction(
|
|
||||||
android.R.drawable.ic_media_next,
|
|
||||||
"Next",
|
|
||||||
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
||||||
this,
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(isPlaying)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
|
||||||
return mediaSession
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable remote volume control for remote playback (e.g., casting to Jellyfin session).
|
|
||||||
* Volume button presses will be sent to Rust for forwarding to the remote session.
|
|
||||||
*
|
|
||||||
* Uses MediaSessionCompat with VolumeProviderCompat to intercept hardware volume buttons.
|
|
||||||
*
|
|
||||||
* @param initialVolume Initial volume level (0-100)
|
|
||||||
*/
|
|
||||||
fun enableRemoteVolume(initialVolume: Int) {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Enabling remote volume control (volume=$initialVolume)")
|
|
||||||
isRemoteVolumeEnabled = true
|
|
||||||
remoteVolumeLevel = initialVolume.coerceIn(0, 100)
|
|
||||||
|
|
||||||
val session = mediaSessionCompat ?: run {
|
|
||||||
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a VolumeProvider for remote volume control
|
|
||||||
volumeProvider = object : VolumeProviderCompat(
|
|
||||||
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, // Control type: absolute volume
|
|
||||||
100, // Max volume (0-100)
|
|
||||||
remoteVolumeLevel // Initial volume
|
|
||||||
) {
|
|
||||||
override fun onSetVolumeTo(volume: Int) {
|
|
||||||
if (!isRemoteVolumeEnabled) return
|
|
||||||
|
|
||||||
remoteVolumeLevel = volume.coerceIn(0, 100)
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume set to $remoteVolumeLevel")
|
|
||||||
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAdjustVolume(direction: Int) {
|
|
||||||
if (!isRemoteVolumeEnabled) return
|
|
||||||
|
|
||||||
when (direction) {
|
|
||||||
android.media.AudioManager.ADJUST_RAISE -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume up to $remoteVolumeLevel")
|
|
||||||
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
|
||||||
// Update the current volume so slider reflects the change
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
android.media.AudioManager.ADJUST_LOWER -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume down to $remoteVolumeLevel")
|
|
||||||
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
|
||||||
// Update the current volume so slider reflects the change
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the volume provider on the media session to route hardware volume buttons
|
|
||||||
session.setPlaybackToRemote(volumeProvider!!)
|
|
||||||
|
|
||||||
// Set playback state to make Android show the volume UI
|
|
||||||
// This tells Android that this session is actively controlling media playback
|
|
||||||
val playbackState = PlaybackStateCompat.Builder()
|
|
||||||
.setState(
|
|
||||||
PlaybackStateCompat.STATE_PLAYING,
|
|
||||||
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
|
||||||
1.0f
|
|
||||||
)
|
|
||||||
.setActions(
|
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
|
||||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
session.setPlaybackState(playbackState)
|
|
||||||
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume control enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable remote volume control and return to local volume control.
|
|
||||||
* Volume buttons will control system media volume (ExoPlayer volume).
|
|
||||||
*/
|
|
||||||
fun disableRemoteVolume() {
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Disabling remote volume control")
|
|
||||||
isRemoteVolumeEnabled = false
|
|
||||||
|
|
||||||
val session = mediaSessionCompat ?: run {
|
|
||||||
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch back to local audio stream (device volume)
|
|
||||||
session.setPlaybackToLocal(android.media.AudioManager.STREAM_MUSIC)
|
|
||||||
|
|
||||||
// Clear the playback state
|
|
||||||
val idleState = PlaybackStateCompat.Builder()
|
|
||||||
.setState(
|
|
||||||
PlaybackStateCompat.STATE_NONE,
|
|
||||||
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
|
||||||
0.0f
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
session.setPlaybackState(idleState)
|
|
||||||
|
|
||||||
// Clear the volume provider
|
|
||||||
volumeProvider = null
|
|
||||||
|
|
||||||
// Reset volume level to default
|
|
||||||
remoteVolumeLevel = 50
|
|
||||||
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume control disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the remote volume level.
|
|
||||||
* Call this when volume changes on the remote session to sync the local state.
|
|
||||||
*
|
|
||||||
* @param volume Volume level (0-100)
|
|
||||||
*/
|
|
||||||
fun updateRemoteVolume(volume: Int) {
|
|
||||||
remoteVolumeLevel = volume.coerceIn(0, 100)
|
|
||||||
// Update the volume provider's current volume so the UI slider reflects the change
|
|
||||||
volumeProvider?.currentVolume = remoteVolumeLevel
|
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Remote volume updated to $remoteVolumeLevel")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
mediaSession?.run {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
mediaSession = null
|
|
||||||
|
|
||||||
mediaSessionCompat?.run {
|
|
||||||
isActive = false
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
mediaSessionCompat = null
|
|
||||||
volumeProvider = null
|
|
||||||
|
|
||||||
instance = null
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
// Stop the service when the app is swiped away, unless audio is playing
|
|
||||||
val player = mediaSession?.player
|
|
||||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JNI callback to Rust for media commands.
|
|
||||||
* Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
|
|
||||||
*/
|
|
||||||
private external fun nativeOnMediaCommand(command: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JNI callback to Rust for remote volume changes.
|
|
||||||
* Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
|
||||||
* @param command The volume command
|
|
||||||
* @param volume The volume level (0-100)
|
|
||||||
*/
|
|
||||||
private external fun nativeOnRemoteVolumeChange(command: String, volume: Int)
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package com.dtourolle.jellytau.security
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
|
||||||
import android.security.keystore.KeyProperties
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import java.security.KeyStore
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyGenerator
|
|
||||||
import javax.crypto.SecretKey
|
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Secure storage for credentials using Android Keystore.
|
|
||||||
* Provides encrypted storage for sensitive data like API tokens.
|
|
||||||
*/
|
|
||||||
class SecureStorage private constructor(context: Context) {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "SecureStorage"
|
|
||||||
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
|
||||||
private const val KEY_ALIAS = "jellytau_credentials_key"
|
|
||||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
||||||
private const val PREFS_NAME = "jellytau_secure_prefs"
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var instance: SecureStorage? = null
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun initialize(context: Context) {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized(this) {
|
|
||||||
if (instance == null) {
|
|
||||||
instance = SecureStorage(context.applicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(): SecureStorage {
|
|
||||||
return instance ?: throw IllegalStateException("SecureStorage not initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
|
||||||
load(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Ensure encryption key exists
|
|
||||||
if (!keyStore.containsAlias(KEY_ALIAS)) {
|
|
||||||
generateKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateKey() {
|
|
||||||
val keyGenerator = KeyGenerator.getInstance(
|
|
||||||
KeyProperties.KEY_ALGORITHM_AES,
|
|
||||||
KEYSTORE_PROVIDER
|
|
||||||
)
|
|
||||||
|
|
||||||
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
|
||||||
KEY_ALIAS,
|
|
||||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
|
||||||
)
|
|
||||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
||||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
||||||
.setRandomizedEncryptionRequired(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
keyGenerator.init(keyGenParameterSpec)
|
|
||||||
keyGenerator.generateKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSecretKey(): SecretKey {
|
|
||||||
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveCredential(key: String, value: String) {
|
|
||||||
try {
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
|
||||||
|
|
||||||
val iv = cipher.iv
|
|
||||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
|
||||||
|
|
||||||
// Store IV + encrypted data as base64
|
|
||||||
val combined = iv + encrypted
|
|
||||||
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
|
||||||
|
|
||||||
prefs.edit().putString(key, encoded).apply()
|
|
||||||
Log.d(TAG, "Saved credential: $key")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to save credential: $key", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCredential(key: String): String? {
|
|
||||||
try {
|
|
||||||
val encoded = prefs.getString(key, null) ?: return null
|
|
||||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
|
||||||
|
|
||||||
// Extract IV (first 12 bytes for GCM)
|
|
||||||
val iv = combined.copyOfRange(0, 12)
|
|
||||||
val encrypted = combined.copyOfRange(12, combined.size)
|
|
||||||
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
||||||
val spec = GCMParameterSpec(128, iv)
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
|
||||||
|
|
||||||
val decrypted = cipher.doFinal(encrypted)
|
|
||||||
return String(decrypted, Charsets.UTF_8)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to get credential: $key", e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteCredential(key: String) {
|
|
||||||
prefs.edit().remove(key).apply()
|
|
||||||
Log.d(TAG, "Deleted credential: $key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// JNI-compatible methods (called from Rust)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a token (JNI-compatible version).
|
|
||||||
* @return true if successful, false otherwise
|
|
||||||
*/
|
|
||||||
@JvmOverloads
|
|
||||||
fun saveToken(key: String, value: String): Boolean {
|
|
||||||
return try {
|
|
||||||
saveCredential(key, value)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "saveToken failed for key: $key", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a token (JNI-compatible version).
|
|
||||||
* @return token string or null if not found
|
|
||||||
*/
|
|
||||||
fun getToken(key: String): String? {
|
|
||||||
return getCredential(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a token (JNI-compatible version).
|
|
||||||
* @return true if successful, false otherwise
|
|
||||||
*/
|
|
||||||
fun deleteToken(key: String): Boolean {
|
|
||||||
return try {
|
|
||||||
deleteCredential(key)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "deleteToken failed for key: $key", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 870 B |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,13 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<!-- Base application theme -->
|
|
||||||
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
|
||||||
<!-- Status bar color -->
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<!-- Make status bar icons dark or light based on background -->
|
|
||||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
|
||||||
<!-- Don't draw behind status bar -->
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
|
||||||
<!-- Ensure content doesn't extend into system bars -->
|
|
||||||
<item name="android:fitsSystemWindows">true</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||