diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 6d577d3..236937d 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -55,8 +55,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - # 8.11.1 = mínimo para AGP 8.9 (compileSdk 36, Appodeal adapters) - gradle-version: '8.11.1' + # 8.11.1 = mínimo para AGP 8.9 (compileSdk 36) + gradle-version: '8.13' # android-actions/setup-android@v3 puede fallar por timeouts transitorios # del runner. Se prueba primero; si falla, se asume que ubuntu-latest ya diff --git a/.github/workflows/echo-build.yml b/.github/workflows/echo-build.yml new file mode 100644 index 0000000..5308395 --- /dev/null +++ b/.github/workflows/echo-build.yml @@ -0,0 +1,151 @@ +name: Echo module (rama) + +# Workflow de iteración para el injerto del reproductor Echo Nightly. +# Solo compila (no publica release) y se dispara en ramas de trabajo, para +# poder iterar sin tocar main ni romper el pipeline de APK. +on: + push: + branches: + - 'feat/**' + - 'echo/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Compilar :echo + :app (debug) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Java JDK 17 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + gradle-version: '8.13' + + - name: Setup Android SDK + id: setup-android + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + continue-on-error: true + + - name: Accept SDK licenses + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ -f "$SDKMANAGER" ]; then + yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true + fi + + - name: Compile :echo (debug) + id: echo + working-directory: apk/android + run: | + set +e + gradle :echo:assembleDebug --stacktrace > "$RUNNER_TEMP/echo-build.log" 2>&1 + rc=$? + if [ $rc -eq 0 ]; then : > "$RUNNER_TEMP/echo-ok"; fi + echo "== :echo:assembleDebug rc=$rc ==" + if [ -f "$RUNNER_TEMP/echo-build.log" ]; then tail -n 40 "$RUNNER_TEMP/echo-build.log"; fi + exit $rc + + - name: Compile :app (debug, con :echo) + working-directory: apk/android + run: | + set +e + gradle :app:assembleDebug --stacktrace > "$RUNNER_TEMP/app-build.log" 2>&1 + rc=$? + echo "gradle_rc=$rc" >> "$GITHUB_ENV" + if [ $rc -eq 0 ]; then : > "$RUNNER_TEMP/app-build-ok"; fi + exit $rc + + - name: Resumen de errores + if: always() + run: | + ERRPAT="^e: |error:|Error:|FAILURE:|What went wrong|Execution failed for task|Caused by:|Could not (resolve|find|determine|download|open)|Manifest merger|is also present at|needs to be explicitly specified|Suggestion:|Duplicate class|Duplicate resources|Duplicate entry|AAPT|Android resource linking failed" + MR="app/build/outputs/logs/manifest-merger-debug-report.txt" + for L in "$RUNNER_TEMP/app-build.log" "$RUNNER_TEMP/echo-build.log"; do + [ -f "$L" ] || continue + echo "===== $L (ERRORES) =====" + grep -nE -A2 "$ERRPAT" "$L" | head -n 80 || true + done + if [ -f "$MR" ]; then + echo "===== $MR (ERRORES) =====" + grep -nE "Error|error|ERROR|also present|Suggestion|needs to be explicitly" "$MR" | head -n 60 || true + fi + # Anotaciones de check públicas (leíbles por la API sin admin), app + # primero y sin warnings, para poder diagnosticar sin acceso al log. + { + for L in "$RUNNER_TEMP/app-build.log" "$RUNNER_TEMP/echo-build.log"; do + [ -f "$L" ] || continue + grep -nE "$ERRPAT" "$L" 2>/dev/null | sed "s|^|${L##*/}: |" + done + if [ -f "$MR" ]; then + grep -nE "Error:|is also present at|needs to be explicitly specified|Suggestion:" "$MR" 2>/dev/null | sed 's|^|merger: |' + fi + } > "$RUNNER_TEMP/errs.txt" || true + if [ -f "$RUNNER_TEMP/errs.txt" ]; then + head -n 14 "$RUNNER_TEMP/errs.txt" | while IFS= read -r line; do + clean=$(printf '%s' "$line" | tr -d '\r' | sed 's/%/%25/g' | cut -c1-500) + echo "::error::$clean" + done + fi + exit 0 + + - name: Fail si :app no compila + if: always() + run: | + if [ -f "$RUNNER_TEMP/app-build-ok" ]; then echo "compile :app OK"; exit 0; fi + echo "::error:::app:assembleDebug falló (ver resumen/logs)" + exit 1 + + - name: Verificar componentes media3 unicos + if: always() + run: | + # media3 exige EXACTAMENTE un receptor ACTION_MEDIA_BUTTON + # (MediaSessionLegacyStub) y un servicio MediaLibraryService + # (MediaButtonReceiver). Con dos de cualquiera arranca el crash: + # "Expected 1 ... found 2". Se valida sobre el manifiesto fusionado + # para no depender de probar en dispositivo. + MM=$(find apk/android/app/build/intermediates -name AndroidManifest.xml 2>/dev/null | grep -iE 'merged_manifest' | head -n 1) + if [ -z "$MM" ]; then + echo "::warning::No se encontró el manifiesto fusionado; se omite la verificación." + exit 0 + fi + echo "Manifiesto fusionado: $MM" + RECV=$(grep -c '","offerwall":""} +# MONETAG_POSTBACK_SECRET # atajo por red (también ADGEM_/AYET_/BITLABS_) +REWARD_HMAC_SECRET # Firma de nonces (def. ADMIN_KEY) +REWARD_AMOUNTS # JSON {"default":1,"bonus":5} +REWARD_DAILY_CAP # Tope de créditos/usuario/día (def. 50) +REWARD_AD_UNIT # Unidad por defecto (def. "default") +REWARD_USER_ID_SOURCES # CSV redes vía user_id: "adgem,bitlabs" +# App: Unity Ads — Game ID y placements en +# apk/android/app/src/main/java/com/codehub/app/UnityAdsManager.java ``` ## 🤖 AutoScript diff --git a/apk/android/app/build.gradle b/apk/android/app/build.gradle index c30cb6f..b8c6e75 100644 --- a/apk/android/app/build.gradle +++ b/apk/android/app/build.gradle @@ -46,8 +46,9 @@ android { compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 - // minSdk 24 < 26 → necesario para las APIs `java.time` que usa el - // adaptador de Appodeal/AdMob en runtime sin subir el minSdk. + // minSdk 24 < 26 → necesario para las APIs `java.time` que usan + // (en runtime) las librerías del stack de anuncios/analytics sin subir + // el minSdk (ver AlexParser/AutoLauncher y el fork de Echo). coreLibraryDesugaringEnabled true } } @@ -59,6 +60,9 @@ kotlin { } dependencies { + // Reproductor completo de Echo Nightly (see apk/android/echo). Se lanza + // desde el panel Música de CodeHub (CodeHubBridge.openMusic). + implementation project(':echo') implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.webkit:webkit:1.9.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' @@ -73,20 +77,9 @@ dependencies { // SDK de Google (com.google.android.ump), distinto de play-services-ads. // Debe consultarse antes de pedir anuncios en regiones exigidas (EEE/UK/Canadá). implementation 'com.google.android.ump:user-messaging-platform:3.2.0' - // Appodeal 4.4.0 (Mediation Only) — sustituye las llamadas directas a - // AdMob (BannerAdManager/RewardedAdManager/InterstitialAdManager). El - // adaptador de AdMob trae play-services-ads y exige compileSdk 36; las - // unidades de banner/rewarded/interstitial se configuran en el panel de - // Appodeal (APP_KEY en AppodealManager). Repo: artifactory.appodeal.com. - implementation 'com.appodeal.ads.sdk:core:4.4.0' - implementation 'com.appodeal.ads.sdk.adapters:admob:25.2.0.0' - implementation 'com.appodeal.ads.sdk.adapters:applovin:13.6.3.0' - implementation 'com.appodeal.ads.sdk.adapters:unity_ads:4.17.0.0' - implementation 'com.appodeal.ads.sdk.adapters:vungle:7.7.4.0' - implementation 'com.appodeal.ads.sdk.adapters:bidmachine:3.7.1.0' - // iab — puente TCF: Appodeal lee los strings IABTCF que escribe UMP - // (ConsentManager.java) para respetar la decisión del usuario. - implementation 'com.appodeal.ads.sdk.adapters:iab:1.8.1.1' + // Unity Ads (SDK directo, sin mediación). Game ID y placements se declaran + // en UnityAdsManager.java; el banner vive en la franja reservada del layout. + implementation 'com.unity3d.ads:unity-ads:4.20.0' // Desugaring de librerías java.time (ver compileOptions arriba). coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' implementation 'com.google.android.gms:play-services-location:21.3.0' @@ -109,8 +102,8 @@ dependencies { // Licencia de las extensiones: UPL-1.0 / GPLv3 - ver nota en apk/README.md. // Se excluye protobuf-java: echo common lo declara en su POM pero el AAR // common-android (282 clases, verificado) NO referencia com.google.protobuf - // en su bytecode; el stack Appodeal/AdMob trae protobuf-javalite:3.25.6 y - // ambos artefactos comparten clases com.google.protobuf.* → duplicadas. + // en su bytecode. El exclude es defensivo para no arrastrar una copia + // duplicada de com.google.protobuf.* junto a otras dependencias. implementation('dev.brahmkshatriya.echo:common:1.0.0') { exclude group: 'com.google.protobuf', module: 'protobuf-java' } diff --git a/apk/android/app/src/main/AndroidManifest.xml b/apk/android/app/src/main/AndroidManifest.xml index 86f5784..a0a4f61 100644 --- a/apk/android/app/src/main/AndroidManifest.xml +++ b/apk/android/app/src/main/AndroidManifest.xml @@ -111,28 +111,27 @@ android:windowSoftInputMode="adjustResize"> - + - - - - - - - - 8; - } - - /** - * Inicializa Appodeal (una sola vez), tras el consentimiento resuelto - * por UMP. Se llama desde MainActivity.initAdMob dentro de la callback - * de ConsentManager, en el hilo principal. - */ - static void init(final Activity activity, final View bottomSlot) { - if (initialized || !hasAppKey() || !ConsentManager.canRequestAds()) return; - try { - initialized = true; - bannerSlot = bottomSlot; - - int adTypes = Appodeal.BANNER | Appodeal.REWARDED_VIDEO | Appodeal.INTERSTITIAL; - Appodeal.initialize(activity, APPODEAL_APP_KEY, adTypes, new ApdInitializationCallback() { - @Override - public void onInitializationFinished(List errors) { - if (errors != null && !errors.isEmpty()) { - Log.w(TAG, "Appodeal inicializado con " + errors.size() + " error(es)"); - } else { - Log.i(TAG, "Appodeal inicializado"); - } - setupBanner(activity); - } - }); - - Appodeal.setBannerCallbacks(new BannerCallbacks() { - @Override - public void onBannerLoaded(int height, boolean isPrecache) { - Log.i(TAG, "banner cargado (height=" + height + ", precache=" + isPrecache + ")"); - } - - @Override - public void onBannerFailedToLoad() { - Log.w(TAG, "banner no se pudo cargar"); - } - - @Override - public void onBannerShown() { - Log.i(TAG, "banner mostrado"); - } - - @Override - public void onBannerShowFailed() { - Log.w(TAG, "banner no se pudo mostrar"); - } - - @Override - public void onBannerClicked() { - Log.i(TAG, "banner clickeado"); - } - - @Override - public void onBannerExpired() { - Log.w(TAG, "banner expirado (se recargará solo por autocache)"); - } - }); - - Appodeal.setRewardedVideoCallbacks(new RewardedVideoCallbacks() { - @Override - public void onRewardedVideoLoaded(boolean isPrecache) { - Log.i(TAG, "rewarded cargado (precache=" + isPrecache + ")"); - } - - @Override - public void onRewardedVideoFailedToLoad() { - Log.w(TAG, "rewarded no se pudo cargar"); - } - - @Override - public void onRewardedVideoShown() { - Log.i(TAG, "rewarded mostrado"); - } - - @Override - public void onRewardedVideoShowFailed() { - Log.w(TAG, "rewarded no se pudo mostrar"); - deliverRewarded(false, 0, ""); - } - - @Override - public void onRewardedVideoClicked() { - Log.i(TAG, "rewarded clickeado"); - } - - @Override - public void onRewardedVideoFinished(double amount, String currency) { - rewardedAmount = amount; - rewardedCurrency = currency == null ? "" : currency; - rewardedFinished = true; - } - - @Override - public void onRewardedVideoClosed(boolean finished) { - if (finished && rewardedFinished) { - deliverRewarded(true, (int) Math.round(rewardedAmount), rewardedCurrency); - } else { - deliverRewarded(false, 0, ""); - } - } - - @Override - public void onRewardedVideoExpired() { - Log.w(TAG, "rewarded expirado"); - } - }); - - Appodeal.setInterstitialCallbacks(new InterstitialCallbacks() { - @Override - public void onInterstitialLoaded(boolean isPrecache) { - Log.i(TAG, "interstitial cargado (precache=" + isPrecache + ")"); - } - - @Override - public void onInterstitialFailedToLoad() { - Log.w(TAG, "interstitial no se pudo cargar"); - } - - @Override - public void onInterstitialShown() { - interstitialShown = true; - } - - @Override - public void onInterstitialShowFailed() { - deliverInterstitial(false); - } - - @Override - public void onInterstitialClicked() { - Log.i(TAG, "interstitial clickeado"); - } - - @Override - public void onInterstitialClosed() { - deliverInterstitial(interstitialShown); - } - - @Override - public void onInterstitialExpired() { - Log.w(TAG, "interstitial expirado"); - } - }); - } catch (Throwable t) { - initialized = false; - Log.w(TAG, "error en init", t); - } - } - - /** - * Banner en la franja inferior reservada de MainActivity (BannerView, - * nunca superpuesto al contenido). Se crea al terminar la inicialización - * del SDK, igual que los managers AdMob previos (visible por defecto; - * JS puede ocultarlo con hideNativeBanner). - */ - private static void setupBanner(final Activity activity) { - if (bannerView != null || bannerSlot == null) return; - try { - bannerView = Appodeal.getBannerView(activity); - FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - lp.gravity = Gravity.CENTER_HORIZONTAL; - ((FrameLayout) bannerSlot).addView(bannerView, lp); - bannerSlot.setVisibility(View.VISIBLE); - Appodeal.show(activity, Appodeal.BANNER_VIEW, PLACEMENT); - Log.i(TAG, "banner (BannerView) configurado en la franja inferior"); - } catch (Throwable t) { - Log.w(TAG, "setup del banner falló", t); - } - } - - static void showBanner() { - if (bannerView != null) bannerView.setVisibility(View.VISIBLE); - if (bannerSlot != null) bannerSlot.setVisibility(View.VISIBLE); - } - - static void hideBanner() { - if (bannerView != null) bannerView.setVisibility(View.GONE); - if (bannerSlot != null) bannerSlot.setVisibility(View.GONE); - } - - // ── REWARDED ─────────────────────────────────────────────────── - - static void loadRewarded(Activity activity) { - if (!initialized) return; - try { Appodeal.cache(activity, Appodeal.REWARDED_VIDEO); } catch (Throwable t) { Log.w(TAG, "cache rewarded", t); } - } - - static boolean isRewardedReady() { - return initialized && Appodeal.canShow(Appodeal.REWARDED_VIDEO, PLACEMENT); - } - - static void showRewarded(final Activity activity, RewardedCallback cb) { - if (!initialized || cb == null) { if (cb != null) cb.onResult(false, 0, ""); return; } - if (!Appodeal.canShow(Appodeal.REWARDED_VIDEO, PLACEMENT)) { - cb.onResult(false, 0, ""); - return; - } - synchronized (AppodealManager.class) { - rewardedCallback = cb; - rewardedFinished = false; - rewardedAmount = 0; - rewardedCurrency = ""; - } - boolean ok = Appodeal.show(activity, Appodeal.REWARDED_VIDEO, PLACEMENT); - if (!ok) deliverRewarded(false, 0, ""); - } - - private static void deliverRewarded(final boolean earned, final int amount, final String currency) { - RewardedCallback cb; - synchronized (AppodealManager.class) { - cb = rewardedCallback; - rewardedCallback = null; - } - if (cb != null) cb.onResult(earned, amount, currency); - } - - // ── INTERSTITIAL ────────────────────────────────────────────── - - static void loadInterstitial(Activity activity) { - if (!initialized) return; - try { Appodeal.cache(activity, Appodeal.INTERSTITIAL); } catch (Throwable t) { Log.w(TAG, "cache interstitial", t); } - } - - static boolean isInterstitialReady() { - return initialized && Appodeal.canShow(Appodeal.INTERSTITIAL, PLACEMENT); - } - - static void showInterstitial(final Activity activity, InterstitialCallback cb) { - if (!initialized || cb == null) { if (cb != null) cb.onResult(false); return; } - if (!Appodeal.canShow(Appodeal.INTERSTITIAL, PLACEMENT)) { - cb.onResult(false); - return; - } - synchronized (AppodealManager.class) { - interstitialCallback = cb; - interstitialShown = false; - } - boolean ok = Appodeal.show(activity, Appodeal.INTERSTITIAL, PLACEMENT); - if (!ok) deliverInterstitial(false); - } - - private static void deliverInterstitial(final boolean shown) { - InterstitialCallback cb; - synchronized (AppodealManager.class) { - cb = interstitialCallback; - interstitialCallback = null; - } - if (cb != null) cb.onResult(shown); - } -} \ No newline at end of file diff --git a/apk/android/app/src/main/java/com/codehub/app/CodeHubApp.java b/apk/android/app/src/main/java/com/codehub/app/CodeHubApp.java index bb33a16..b01a434 100644 --- a/apk/android/app/src/main/java/com/codehub/app/CodeHubApp.java +++ b/apk/android/app/src/main/java/com/codehub/app/CodeHubApp.java @@ -19,8 +19,16 @@ public void onCreate() { // Si quedó un crash.log de una sesión anterior que no pudo enviarse // (sin internet en el momento del crash), lo reintenta ahora. CrashReporter.flushPendingLog(this); - // Appodeal (mediación) se inicializa en MainActivity tras el gate de - // UMP (ConsentManager) — el adaptador de AdMob se inicializa solo. + // Arranque del reproductor Echo Nightly (módulo :echo): enciende Koin y + // el WorkManager factory. DEFENSIVO: si falla, CodeHub sigue igual y + // CodeHubBridge.openMusic cae al reproductor "slide" nativo. + try { + dev.brahmkshatriya.echo.EchoBoot.start(this); + } catch (Throwable t) { + Log.e("CodeHub", "Echo no arrancó (Koin): " + t, t); + } + // Unity Ads se inicializa en MainActivity (UnityAdsManager) tras el + // gate de consentimiento UMP (ConsentManager). // Lanzar Foreground Service lo antes posible para WakeLock + sync CodeHubSyncService.startIfNotRunning(this); } diff --git a/apk/android/app/src/main/java/com/codehub/app/CodeHubBridge.java b/apk/android/app/src/main/java/com/codehub/app/CodeHubBridge.java index 1fa3cec..4537060 100644 --- a/apk/android/app/src/main/java/com/codehub/app/CodeHubBridge.java +++ b/apk/android/app/src/main/java/com/codehub/app/CodeHubBridge.java @@ -468,15 +468,15 @@ public void openExternal(String url) { } catch (Exception ignored) {} } - // ── ANUNCIO RECOMPENSADO (Appodeal) ──────────────────────────── + // ── ANUNCIO RECOMPENSADO (Unity Ads) ─────────────────────────── @JavascriptInterface public void loadRewardedAd() { - activity.runOnUiThread(() -> AppodealManager.loadRewarded(activity)); + activity.runOnUiThread(UnityAdsManager::loadRewarded); } @JavascriptInterface public boolean isRewardedAdReady() { - return AppodealManager.isRewardedReady(); + return UnityAdsManager.isRewardedReady(); } // callbackName: nombre de una función global en window, invocada como @@ -484,43 +484,43 @@ public boolean isRewardedAdReady() { // vio el anuncio completo y ganó la recompensa. @JavascriptInterface public void showRewardedAd(final String callbackName) { - activity.runOnUiThread(() -> AppodealManager.showRewarded(activity, (earned, amount, type) -> { + activity.runOnUiThread(() -> UnityAdsManager.showRewarded(activity, (earned, amount, type) -> { String safeType = type == null ? "" : type.replace("'", "\\'"); webView.loadUrl("javascript:try{if(window." + callbackName + ")window." + callbackName + "(" + earned + "," + amount + ",'" + safeType + "');}catch(e){}"); })); } - // ── ANUNCIO INTERSTICIAL (Appodeal) ──────────────────────────── + // ── ANUNCIO INTERSTICIAL (Unity Ads) ─────────────────────────── @JavascriptInterface public void loadInterstitialAd() { - activity.runOnUiThread(() -> AppodealManager.loadInterstitial(activity)); + activity.runOnUiThread(UnityAdsManager::loadInterstitial); } @JavascriptInterface public boolean isInterstitialAdReady() { - return AppodealManager.isInterstitialReady(); + return UnityAdsManager.isInterstitialReady(); } // callbackName: invocada como callbackName(shown) — shown=true si el // anuncio se mostró y se cerró (o false si no estaba listo). @JavascriptInterface public void showInterstitialAd(final String callbackName) { - activity.runOnUiThread(() -> AppodealManager.showInterstitial(activity, shown -> { + activity.runOnUiThread(() -> UnityAdsManager.showInterstitial(activity, shown -> { webView.loadUrl("javascript:try{if(window." + callbackName + ")window." + callbackName + "(" + shown + ");}catch(e){}"); })); } - // ── BANNER NATIVO (Appodeal, franja inferior) ────────────────── + // ── BANNER NATIVO (Unity Ads, franja inferior) ───────────────── @JavascriptInterface public void showNativeBanner() { - activity.runOnUiThread(AppodealManager::showBanner); + activity.runOnUiThread(UnityAdsManager::showBanner); } @JavascriptInterface public void hideNativeBanner() { - activity.runOnUiThread(AppodealManager::hideBanner); + activity.runOnUiThread(UnityAdsManager::hideBanner); } @JavascriptInterface @@ -884,9 +884,29 @@ public void openDownloads() { } catch (Exception ignored) {} } - /** Abre el reproductor de música "slide" de CodeHub (extensiones Echo). */ + /** Abre el reproductor completo de Echo Nightly injertado en CodeHub + * (módulo :echo). Si el arranque de Koin falló o la activity no está, + * cae al reproductor "slide" nativo para no dejar al usuario sin música. */ @JavascriptInterface public void openMusic() { + try { + dev.brahmkshatriya.echo.EchoBoot.start(activity); + } catch (Throwable t) { + android.util.Log.e("CodeHub", "Echo no arrancó (openMusic): " + t, t); + } + if (dev.brahmkshatriya.echo.EchoBoot.isReady()) { + try { + // Echo alterna MainActivity y MainActivity$Back segun el ajuste + // "back animation" (habilita uno y deshabilita el otro), así que + // el nombre fijo de la clase no sirve: se resuelve el vigente. + Class echoEntry = dev.brahmkshatriya.echo.EchoBoot.mainActivity(activity); + Intent i = new Intent(activity, echoEntry); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(i); + return; + } catch (Exception ignored) {} + } + // Fallback: reproductor "slide" nativo de CodeHub. try { Intent i = new Intent(activity, com.codehub.app.music.ui.MusicPlayerActivity.class); i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); diff --git a/apk/android/app/src/main/java/com/codehub/app/MainActivity.java b/apk/android/app/src/main/java/com/codehub/app/MainActivity.java index 4eaf4d0..9d1e3a1 100644 --- a/apk/android/app/src/main/java/com/codehub/app/MainActivity.java +++ b/apk/android/app/src/main/java/com/codehub/app/MainActivity.java @@ -155,7 +155,7 @@ protected void onCreate(Bundle savedInstanceState) { handleIntent(getIntent()); } - // ── Anuncios: consentimiento UMP + Appodeal (mediación) ──────────── + // ── Anuncios: consentimiento UMP + Unity Ads ────────────────────── // Nada de anuncios se pide antes de que UMP resuelva el consentimiento // (EEE/UK/Canadá). Fuera de esas regiones lookup UMP resuelve inmediato // con canRequestAds=true y no se bloquea ningún anuncio. @@ -163,7 +163,7 @@ private void initAdMob() { ConsentManager.init(this, () -> { runOnUiThread(() -> { if (!ConsentManager.canRequestAds()) return; - AppodealManager.init(MainActivity.this, bannerSlot); + UnityAdsManager.init(MainActivity.this, bannerSlot); }); }); } diff --git a/apk/android/app/src/main/java/com/codehub/app/UnityAdsManager.java b/apk/android/app/src/main/java/com/codehub/app/UnityAdsManager.java new file mode 100644 index 0000000..8ef7578 --- /dev/null +++ b/apk/android/app/src/main/java/com/codehub/app/UnityAdsManager.java @@ -0,0 +1,287 @@ +package com.codehub.app; + +import android.app.Activity; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.unity3d.ads.IUnityAdsInitializationListener; +import com.unity3d.ads.IUnityAdsLoadListener; +import com.unity3d.ads.IUnityAdsShowListener; +import com.unity3d.ads.UnityAds; +import com.unity3d.ads.UnityAdsShowOptions; +import com.unity3d.services.banners.BannerErrorInfo; +import com.unity3d.services.banners.BannerView; +import com.unity3d.services.banners.UnityBannerSize; + +/** + * Unity Ads (SDK directo) — banners, interstitials y rewarded. + * + * Reemplaza a AppodealManager como motor de anuncios conservando EL MISMO + * contrato JS de CodeHubBridge; por eso CodeHubBridge y js/rewarded-ad.js + * no cambian. Unity Ads no usa "APP_KEY" de mediación: se inicializa con + * el Game ID y se carga/muestra con un Placement ID por formato. + * + * · Game ID 800397583 + * · Rewarded BP_Rewarded_Android + * · Interstitial BP_Interstitial_Android + * · Banner BP_Banner_Android + * + * El consentimiento sigue siendo el de UMP (ConsentManager): solo se llama + * a init() cuando canRequestAds() es true. Nada de anuncios antes de eso. + */ +final class UnityAdsManager { + + private static final String TAG = "CodeHubUnityAds"; + + // Dashboard Unity → proyecto "CodeHub". + private static final String GAME_ID = "800397583"; + + private static final String PLACEMENT_REWARDED = "BP_Rewarded_Android"; + private static final String PLACEMENT_INTERSTITIAL = "BP_Interstitial_Android"; + private static final String PLACEMENT_BANNER = "BP_Banner_Android"; + + /** false = anuncios reales; true = servidor de pruebas de Unity. */ + private static final boolean TEST_MODE = false; + + private static boolean initialized; + + private static View bannerSlot; + private static BannerView bannerView; + + private static RewardedCallback rewardedCallback; + private static boolean rewardedReady; + + private static InterstitialCallback interstitialCallback; + private static boolean interstitialReady; + + /** Contrato JS `cb(earned, amount, type)` de CodeHubBridge. */ + interface RewardedCallback { + void onResult(boolean earned, int amount, String type); + } + + /** Contrato JS `cb(shown)` de CodeHubBridge. */ + interface InterstitialCallback { + void onResult(boolean shown); + } + + private UnityAdsManager() {} + + // ───────────────────────────────────────────────────────────── + // INIT + // ───────────────────────────────────────────────────────────── + static void init(final Activity activity, final View reservedBannerSlot) { + if (initialized || activity == null) return; + bannerSlot = reservedBannerSlot; + try { + UnityAds.initialize(activity, GAME_ID, TEST_MODE, new IUnityAdsInitializationListener() { + @Override + public void onInitializationComplete() { + initialized = true; + Log.i(TAG, "Unity Ads inicializado (" + GAME_ID + ")"); + try { setupBanner(activity); } catch (Throwable t) { Log.w(TAG, "banner setup", t); } + loadRewarded(); + loadInterstitial(); + } + + @Override + public void onInitializationFailed(UnityAds.UnityAdsInitializationError error, String message) { + Log.w(TAG, "init falló: [" + error + "] " + message); + } + }); + } catch (Throwable t) { + Log.w(TAG, "error en init", t); + } + } + + static boolean isInitialized() { + return initialized; + } + + // ───────────────────────────────────────────────────────────── + // REWARDED + // ───────────────────────────────────────────────────────────── + static void loadRewarded() { + if (!initialized) return; + try { + UnityAds.load(PLACEMENT_REWARDED, new IUnityAdsLoadListener() { + @Override + public void onUnityAdsAdLoaded(String placementId) { + rewardedReady = true; + } + + @Override + public void onUnityAdsFailedToLoad(String placementId, UnityAds.UnityAdsLoadError error, String message) { + rewardedReady = false; + Log.w(TAG, "rewarded no cargó: [" + error + "] " + message); + } + }); + } catch (Throwable t) { Log.w(TAG, "loadRewarded", t); } + } + + static boolean isRewardedReady() { + return initialized && rewardedReady; + } + + static void showRewarded(Activity activity, RewardedCallback cb) { + if (!initialized || activity == null || !rewardedReady) { + if (cb != null) cb.onResult(false, 0, ""); + return; + } + rewardedReady = false; + synchronized (UnityAdsManager.class) { rewardedCallback = cb; } + try { + UnityAds.show(activity, PLACEMENT_REWARDED, new UnityAdsShowOptions(), new IUnityAdsShowListener() { + @Override + public void onUnityAdsShowFailure(String placementId, UnityAds.UnityAdsShowError error, String message) { + Log.w(TAG, "rewarded show falló: [" + error + "] " + message); + deliverRewarded(false); + loadRewarded(); + } + + @Override + public void onUnityAdsShowStart(String placementId) {} + + @Override + public void onUnityAdsShowClick(String placementId) {} + + @Override + public void onUnityAdsShowComplete(String placementId, UnityAds.UnityAdsShowCompletionState state) { + boolean completed = state == UnityAds.UnityAdsShowCompletionState.COMPLETED; + deliverRewarded(completed); + loadRewarded(); + } + }); + } catch (Throwable t) { + Log.w(TAG, "showRewarded", t); + deliverRewarded(false); + } + } + + private static void deliverRewarded(boolean earned) { + RewardedCallback cb; + synchronized (UnityAdsManager.class) { + cb = rewardedCallback; + rewardedCallback = null; + } + if (cb != null) cb.onResult(earned, 0, ""); + } + + // ───────────────────────────────────────────────────────────── + // INTERSTITIAL + // ───────────────────────────────────────────────────────────── + static void loadInterstitial() { + if (!initialized) return; + try { + UnityAds.load(PLACEMENT_INTERSTITIAL, new IUnityAdsLoadListener() { + @Override + public void onUnityAdsAdLoaded(String placementId) { + interstitialReady = true; + } + + @Override + public void onUnityAdsFailedToLoad(String placementId, UnityAds.UnityAdsLoadError error, String message) { + interstitialReady = false; + Log.w(TAG, "interstitial no cargó: [" + error + "] " + message); + } + }); + } catch (Throwable t) { Log.w(TAG, "loadInterstitial", t); } + } + + static boolean isInterstitialReady() { + return initialized && interstitialReady; + } + + static void showInterstitial(Activity activity, InterstitialCallback cb) { + if (!initialized || activity == null || !interstitialReady) { + if (cb != null) cb.onResult(false); + return; + } + interstitialReady = false; + synchronized (UnityAdsManager.class) { interstitialCallback = cb; } + try { + UnityAds.show(activity, PLACEMENT_INTERSTITIAL, new UnityAdsShowOptions(), new IUnityAdsShowListener() { + @Override + public void onUnityAdsShowFailure(String placementId, UnityAds.UnityAdsShowError error, String message) { + Log.w(TAG, "interstitial show falló: [" + error + "] " + message); + deliverInterstitial(false); + loadInterstitial(); + } + + @Override + public void onUnityAdsShowStart(String placementId) {} + + @Override + public void onUnityAdsShowClick(String placementId) {} + + @Override + public void onUnityAdsShowComplete(String placementId, UnityAds.UnityAdsShowCompletionState state) { + deliverInterstitial(true); + loadInterstitial(); + } + }); + } catch (Throwable t) { + Log.w(TAG, "showInterstitial", t); + deliverInterstitial(false); + } + } + + private static void deliverInterstitial(boolean shown) { + InterstitialCallback cb; + synchronized (UnityAdsManager.class) { + cb = interstitialCallback; + interstitialCallback = null; + } + if (cb != null) cb.onResult(shown); + } + + // ───────────────────────────────────────────────────────────── + // BANNER (franja reservada, nunca superpuesta) + // ───────────────────────────────────────────────────────────── + private static void setupBanner(Activity activity) { + if (bannerSlot == null) return; + try { + BannerView view = new BannerView(activity, PLACEMENT_BANNER, new UnityBannerSize(320, 50)); + view.setListener(new BannerView.IListener() { + @Override + public void onBannerLoaded(BannerView bannerAdView) { + Log.i(TAG, "banner cargado"); + } + + @Override + public void onBannerFailedToLoad(BannerView bannerAdView, BannerErrorInfo errorInfo) { + Log.w(TAG, "banner no cargó: " + errorInfo.errorCode + " " + errorInfo.errorMessage); + } + + @Override + public void onBannerClick(BannerView bannerAdView) { + Log.i(TAG, "banner clickeado"); + } + + @Override + public void onBannerLeftApplication(BannerView bannerAdView) { + Log.i(TAG, "banner abrió app externa"); + } + }); + bannerSlot.removeAllViews(); + ((ViewGroup) bannerSlot).addView(view, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + bannerSlot.setVisibility(View.VISIBLE); + bannerView = view; + view.load(); + } catch (Throwable t) { + Log.w(TAG, "setup banner", t); + } + } + + static void showBanner() { + if (bannerView != null) bannerView.setVisibility(View.VISIBLE); + if (bannerSlot != null) bannerSlot.setVisibility(View.VISIBLE); + } + + static void hideBanner() { + if (bannerView != null) bannerView.setVisibility(View.GONE); + if (bannerSlot != null) bannerSlot.setVisibility(View.GONE); + } +} diff --git a/apk/android/app/src/main/res/xml/network_security_config.xml b/apk/android/app/src/main/res/xml/network_security_config.xml index 4f0fff2..2e22b89 100644 --- a/apk/android/app/src/main/res/xml/network_security_config.xml +++ b/apk/android/app/src/main/res/xml/network_security_config.xml @@ -3,10 +3,9 @@ wilson360-labs.vercel.app - + diff --git a/apk/android/build.gradle b/apk/android/build.gradle index db1a1a0..5a5a29a 100644 --- a/apk/android/build.gradle +++ b/apk/android/build.gradle @@ -9,9 +9,13 @@ buildscript { } plugins { - // AGP 8.9.2 — requisito mínimo para compileSdk 36 (Appodeal adapters). - // Requiere Gradle 8.11.1+ (ver .github/workflows/build-apk.yml). - id 'com.android.application' version '8.9.2' apply false + // AGP 8.12.2 - requisito minimo para compileSdk 36 (SDK 36). + // Requiere Gradle 8.13+ (ver workflows build-apk/echo-build). + id 'com.android.application' version '8.12.2' apply false + id 'com.android.library' version '8.12.2' apply false id 'com.google.gms.google-services' version '4.5.0' apply false id 'org.jetbrains.kotlin.android' version '2.3.21' apply false + // KSP para Room en el módulo :echo (reproductor Echo Nightly). + id 'com.google.devtools.ksp' version '2.3.12' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21' apply false } diff --git a/apk/android/echo/build.gradle.kts b/apk/android/echo/build.gradle.kts new file mode 100644 index 0000000..0750201 --- /dev/null +++ b/apk/android/echo/build.gradle.kts @@ -0,0 +1,119 @@ +// Módulo :echo — reproductor completo de Echo Nightly injertado en CodeHub. +// +// Se importa el árbol de código de itsmechinmoy/echo-nightly (app/src/main) +// SIN cambios en la lógica, solo el manifiesto se adapta a módulo librería +// (namespace propio, sin launcher) y el arranque (Koin) se delega desde +// CodeHubApp. El contrato de extensiones es el AAR publicado +// dev.brahmkshatriya.echo:common:1.0.0 (la misma versión que declara el +// módulo :common del fork), así que no se compila el módulo KMP. +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "dev.brahmkshatriya.echo" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + // AGP no genera BuildConfig.VERSION_NAME/VERSION_CODE en módulos + // librería (solo en aplicaciones); el código de Echo los usa en + // ContextUtils.appVersion() y en la versión de las extensiones built-in. + buildConfigField("String", "VERSION_NAME", "\"7.17\"") + buildConfigField("int", "VERSION_CODE", "9") + } + + buildFeatures { + buildConfig = true + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + // minSdk 24 < 26: mismas APIs java.time que el resto de CodeHub. + isCoreLibraryDesugaringEnabled = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + + // Contrato de extensiones (mismo artefacto que ya usa CodeHub; se excluye + // protobuf-java porque el AAR no lo referencia y chocaría con el + // protobuf duplicado de otras dependencias). + implementation("dev.brahmkshatriya.echo:common:1.0.0") { + exclude(group = "com.google.protobuf", module = "protobuf-java") + } + + implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.21") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + + // androidx + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity:1.10.1") + implementation("androidx.fragment:fragment-ktx:1.8.9") + implementation("androidx.recyclerview:recyclerview:1.4.0") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + implementation("androidx.core:core-splashscreen:1.0.1") + implementation("androidx.palette:palette-ktx:1.0.0") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("androidx.core:core-google-shortcuts:1.1.0") + implementation("com.google.android.material:material:1.14.0-alpha04") + + // Paging + cache + implementation("androidx.paging:paging-common-ktx:3.3.6") + implementation("androidx.paging:paging-runtime-ktx:3.3.6") + implementation("com.mayakapps.kache:file-kache:2.1.1") + + // Room (persistencia de descargas / extensiones) + implementation("androidx.room:room-runtime:2.7.2") + implementation("androidx.room:room-ktx:2.7.2") + ksp("androidx.room:room-compiler:2.7.2") + + // Koin (DI) + WorkManager factory (Echo elimina el initializer por defecto). + // NO se usa koin-androidx-startup: su Initializer aborta el proceso si la + // Application no implementa KoinStartup. El arranque lo hace EchoBoot. + implementation("io.insert-koin:koin-android:4.1.0") + implementation("io.insert-koin:koin-androidx-workmanager:4.1.0") + + // Media3 (misma versión que CodeHub y el fork) + implementation("androidx.media3:media3-exoplayer:1.8.0") + implementation("androidx.media3:media3-session:1.8.0") + implementation("androidx.media3:media3-ui:1.8.0") + implementation("androidx.media3:media3-exoplayer-dash:1.8.0") + implementation("androidx.media3:media3-exoplayer-hls:1.8.0") + + // Coil 3 (imágenes) + implementation("io.coil-kt.coil3:coil:3.3.0") + implementation("io.coil-kt.coil3:coil-gif:3.3.0") + implementation("io.coil-kt.coil3:coil-svg:3.3.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0") + + // Widgets / UI auxiliar (JitPack) + implementation("com.github.madrapps:pikolo:2.0.2") + implementation("com.github.bosphere.android-fadingedgelayout:fadingedgelayout:1.0.0") + implementation("me.zhanghai.android.fastscroll:library:1.3.0") + implementation("com.flaviofaria:kenburnsview:1.0.7") + implementation("com.telefonica:nestedscrollwebview:0.1.6") + implementation("com.github.acsbendi:Android-Request-Inspector-WebView:1.0.12") + + // Red / serialización que expone common + // Misma variante Android que usa :app y que trae common:1.0.0. Declarar + // okhttp (JVM) junto a okhttp-android provoca clases okhttp3/* duplicadas + // y AGP falla en checkDebugDuplicateClasses (ver :app/build.gradle). + implementation("com.squareup.okhttp3:okhttp-android:5.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") +} diff --git a/apk/android/echo/src/main/AndroidManifest.xml b/apk/android/echo/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a93edb5 --- /dev/null +++ b/apk/android/echo/src/main/AndroidManifest.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoBoot.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoBoot.kt new file mode 100644 index 0000000..ba001cf --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoBoot.kt @@ -0,0 +1,74 @@ +package dev.brahmkshatriya.echo + +import android.content.Context +import coil3.SingletonImageLoader +import dev.brahmkshatriya.echo.MainActivity.Companion.getMainActivity +import dev.brahmkshatriya.echo.di.DI +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.workmanager.koin.workManagerFactory +import org.koin.core.context.startKoin + +/** + * Puente de arranque de Echo dentro de CodeHub. + * + * Echo enciende su contenedor Koin en `MainApplication` implementando + * `KoinStartup` (autodescubierto por `koin-androidx-startup`). Aquí NO usamos + * ese mecanismo: `koin-androidx-startup` aborta el proceso si la Application no + * implementa la interfaz, y no queremos que un fallo del reproductor tumbe el + * resto de CodeHub. En su lugar arrancamos Koin de forma explícita, idempotente + * y defensiva, y [isReady] permite caer al reproductor nativo si algo falla. + * + * `workManagerFactory()` deja WorkManager inicializado con el KoinWorkerFactory + * (Echo elimina el `WorkManagerInitializer` por defecto en su manifiesto). + */ +object EchoBoot { + + private val lock = Any() + + @Volatile + private var ready = false + + /** ¿Koin (y el WorkManager factory) del reproductor quedó operativo? */ + @JvmStatic + fun isReady(): Boolean = ready + + /** + * Activity de entrada de Echo. Echo alterna `MainActivity` y + * `MainActivity$Back` (ajuste "back animation") habilitando uno y + * deshabilitando el otro con `setComponentEnabledSetting`. Lanzar siempre + * `MainActivity` fallaría cuando BACK_ANIM está activo (queda deshabilitada); + * este accesor devuelve la clase habilitada correcta. + */ + @JvmStatic + fun mainActivity(context: Context): Class<*> = context.getMainActivity() + + /** + * Enciende Koin para el reproductor Echo. Idempotente y seguro de llamar + * varias veces. Si falla, lanza; el llamador (CodeHubApp) lo captura para + * no tumbar CodeHub. + * + * Además reproduce los efectos de `MainApplication.onCreate` que sí afectan + * al reproductor y que se perdían al ser CodeHubApp la Application real: + * - registra el loader de imágenes de Echo (SingletonImageLoader), + * - aplica el idioma persistido de Echo (mismo `applyLocale`). + */ + @JvmStatic + fun start(context: Context) { + if (ready) return + synchronized(lock) { + if (ready) return + startKoin { + androidContext(context.applicationContext) + modules(DI.appModule) + workManagerFactory() + } + // Sin esto Coil usa el loader por defecto con hardware bitmaps y + // el palette/GIF de Echo puede fallar. + runCatching { SingletonImageLoader.setSafe { ctx -> EchoImageLoader.build(ctx) } } + // Idioma elegido en Echo (por defecto "system" = sin override). + runCatching { MainApplication.applyLocale(context.getSettings()) } + ready = true + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoFileProvider.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoFileProvider.kt new file mode 100644 index 0000000..cb6fd13 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoFileProvider.kt @@ -0,0 +1,19 @@ +package dev.brahmkshatriya.echo + +import androidx.core.content.FileProvider + +/** + * Subclase de [FileProvider] solo para el injerto en CodeHub. + * + * CodeHub (`:app`) ya declara `androidx.core.content.FileProvider` con la + * authority `${applicationId}.fileprovider`. El manifest merger fusiona los + * `` por `android:name`, así que dos entradas con la misma clase pero + * distinta authority (y distinto `FILE_PROVIDER_PATHS`) son un conflicto duro + * ("Manifest merger failed with multiple errors"). + * + * Al declarar aquí una clase propia, el provider de Echo pasa a ser un nodo + * distinto y ambos conviven. La authority sigue siendo `${applicationId}.provider` + * (la que usa [dev.brahmkshatriya.echo.extensions.InstallationUtils] vía + * `FileProvider.getUriForFile`), que resuelve por authority, no por clase. + */ +class EchoFileProvider : FileProvider() diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoImageLoader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoImageLoader.kt new file mode 100644 index 0000000..0df245f --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/EchoImageLoader.kt @@ -0,0 +1,42 @@ +package dev.brahmkshatriya.echo + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import coil3.request.allowHardware +import coil3.request.crossfade + +/** + * Configuración de imágenes (Coil 3) de Echo. + * + * El código original vivía en `MainApplication.newImageLoader` porque el + * Application era `MainApplication`. En el injerto, la Application real es + * CodeHubApp y `:app` no puede implementar `SingletonImageLoader.Factory` + * (Coil es un detalle interno de `:echo`). Por eso el loader se registra desde + * [EchoBoot] vía `SingletonImageLoader.setSafe`. + * + * `allowHardware(false)` NO es cosmético: con hardware bitmaps el palette y los + * GIF animados de Echo fallan (no se puede leer el bitmap en CPU). + */ +object EchoImageLoader { + + fun build(context: PlatformContext): ImageLoader { + return ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image-cache")) + .maxSizeBytes(1024 * 1024 * 100) // 100MB + .build() + } + .allowHardware(false) + .crossfade(true) + .build() + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt new file mode 100644 index 0000000..a1360d8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt @@ -0,0 +1,117 @@ +package dev.brahmkshatriya.echo + +import android.content.Context +import android.graphics.Color.TRANSPARENT +import android.os.Bundle +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.fragment.app.add +import androidx.fragment.app.commit +import com.google.android.material.color.DynamicColors +import com.google.android.material.color.DynamicColorsOptions +import com.google.android.material.navigation.NavigationBarView +import dev.brahmkshatriya.echo.databinding.ActivityMainBinding +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.setupExceptionHandler +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.setupIntents +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.setupSnackBar +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.setupNavBarAndInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.setupPlayerBehavior +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel.Companion.configureExtensionsUpdater +import dev.brahmkshatriya.echo.ui.main.MainFragment +import dev.brahmkshatriya.echo.ui.player.PlayerFragment +import dev.brahmkshatriya.echo.ui.player.PlayerFragment.Companion.PLAYER_COLOR +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.PermsUtils.checkAppPermissions +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isNightMode +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel + +open class MainActivity : AppCompatActivity() { + + class Back : MainActivity() + + val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } + private val uiViewModel by viewModel() + private val extensionLoader by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(getAppTheme()) + DynamicColors.applyToActivityIfAvailable( + this, applyUiChanges(this, uiViewModel) + ) + + setContentView(binding.root) + + enableEdgeToEdge( + SystemBarStyle.auto(TRANSPARENT, TRANSPARENT), + if (isNightMode()) SystemBarStyle.dark(TRANSPARENT) + else SystemBarStyle.light(TRANSPARENT, TRANSPARENT) + ) + + setupNavBarAndInsets(uiViewModel, binding.root, binding.navView as NavigationBarView) + setupPlayerBehavior(uiViewModel, binding.playerFragmentContainer) + setupExceptionHandler(setupSnackBar(uiViewModel, binding.root)) + checkAppPermissions { extensionLoader.setPermGranted() } + configureExtensionsUpdater() + supportFragmentManager.commit { + if (savedInstanceState != null) return@commit + add(R.id.navHostFragment, "main") + add(R.id.playerFragmentContainer, "player") + } + setupIntents(uiViewModel) + } + + companion object { + const val THEME_KEY = "theme" + const val AMOLED_KEY = "amoled" + const val BIG_COVER = "big_cover" + const val CUSTOM_THEME_KEY = "custom_theme" + const val COLOR_KEY = "color" + + fun Context.getAppTheme(): Int { + val settings = getSettings() + val bigCover = settings.getBoolean(BIG_COVER, false) + val amoled = settings.getBoolean(AMOLED_KEY, false) + return when { + amoled && bigCover -> R.style.AmoledBigCover + amoled -> R.style.Amoled + bigCover -> R.style.BigCover + else -> R.style.Default + } + } + + fun Context.defaultColor() = + ContextCompat.getColor(this, R.color.app_color) + + fun Context.isAmoled() = getSettings().getBoolean(AMOLED_KEY, false) + + fun applyUiChanges(context: Context, uiViewModel: UiViewModel): DynamicColorsOptions { + val settings = context.getSettings() + val mode = when (settings.getString(THEME_KEY, "system")) { + "light" -> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(mode) + + val custom = settings.getBoolean(CUSTOM_THEME_KEY, true) + val color = if (custom) settings.getInt(COLOR_KEY, context.defaultColor()) else null + val playerColor = settings.getBoolean(PLAYER_COLOR, false) + val customColor = uiViewModel.playerColors.value?.accent?.takeIf { playerColor } + + val builder = DynamicColorsOptions.Builder() + (customColor ?: color)?.let { builder.setContentBasedSource(it) } + return builder.build() + } + + const val BACK_ANIM = "back_anim" + fun Context.getMainActivity() = if (getSettings().getBoolean(BACK_ANIM, false)) + Back::class.java else MainActivity::class.java + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainApplication.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainApplication.kt new file mode 100644 index 0000000..2740b4d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/MainApplication.kt @@ -0,0 +1,131 @@ +package dev.brahmkshatriya.echo + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.os.Looper +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import androidx.core.os.LocaleListCompat +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import dev.brahmkshatriya.echo.di.DI +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.utils.AppShortcuts.configureAppShortcuts +import dev.brahmkshatriya.echo.utils.CoroutineUtils +import org.koin.android.ext.android.inject +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.workmanager.koin.workManagerFactory +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.koinConfiguration + +@OptIn(KoinExperimentalAPI::class) +class MainApplication : Application(), SingletonImageLoader.Factory { + + fun onKoinStartup() = koinConfiguration { + androidContext(this@MainApplication) + modules(DI.appModule) + workManagerFactory() + } + + private val settings by inject() + private val extensionLoader by inject() + + override fun onCreate() { + super.onCreate() + CoroutineUtils.setDebug() + applyLocale(settings) + configureAppShortcuts(extensionLoader) + } + + override fun newImageLoader(context: PlatformContext): ImageLoader { + // Config única compartida (ver EchoImageLoader); se registra como + // singleton desde EchoBoot cuando MainApplication no es la Application. + return EchoImageLoader.build(context) + } + + override fun getPackageName(): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) runCatching { + val stackTrace = Looper.getMainLooper().thread.stackTrace + val isChromiumCall = stackTrace.any { trace -> + trace.className.equals(CLASS_NAME, ignoreCase = true) + && FUNCTION_SET.any { trace.methodName.equals(it, ignoreCase = true) } + } + if (isChromiumCall) return spoofedPackageName(applicationContext) + } + return super.getPackageName() + } + + private fun spoofedPackageName(context: Context): String { + return runCatching { + context.packageManager.getPackageInfo(CHROME_PACKAGE, PackageManager.GET_META_DATA) + CHROME_PACKAGE + }.getOrElse { + SYSTEM_SETTINGS_PACKAGE + } + } + + companion object { + private const val CHROME_PACKAGE = "com.android.chrome" + private const val SYSTEM_SETTINGS_PACKAGE = "com.android.settings" + private const val CLASS_NAME = "org.chromium.base.BuildInfo" + private val FUNCTION_SET = setOf("getAll", "getPackageName", "") + + fun getCurrentLanguage(sharedPref: SharedPreferences) = + sharedPref.getString("language", null) ?: "system" + + fun setCurrentLanguage(sharedPref: SharedPreferences, locale: String?) { + sharedPref.edit { putString("language", locale) } + applyLocale(sharedPref) + } + + fun applyLocale(sharedPref: SharedPreferences) { + val value = sharedPref.getString("language", null) ?: "system" + val locale = if (value == "system") LocaleListCompat.getEmptyLocaleList() + else LocaleListCompat.forLanguageTags(value) + AppCompatDelegate.setApplicationLocales(locale) + } + + val languages = mapOf( + "ar" to "العربية", + "as" to "Assamese", + "be" to "Беларуская", + "bn" to "বাংলা", + "ca" to "Català", + "de" to "Deutsch", + "es" to "Español", + "fa" to "فارسی", + "fr" to "Français", + "en" to "English", + "hi" to "हिन्दी", + "hng" to "Hinglish", + "hu" to "Magyar", + "in" to "Bahasa Indonesia", + "it" to "Italiano", + "iw" to "עברית", + "ja" to "日本語", + "ko" to "한국어", + "lv" to "Latviski", + "ms" to "Bahasa Melayu", + "pl" to "Polski", + "pt" to "Português", + "pt-rBR" to "Português (Brasil)", + "ru" to "Русский", + "sa" to "संस्कृतम्", + "si" to "සිංහල", + "sk" to "Slovenčina", + "sr" to "Српски", + "ta" to "தமிழ்", + "th" to "ไทย", + "tl" to "Filipino", + "tr" to "Türkçe", + "uk" to "Українська", + "vi" to "Tiếng Việt", + "zh-rCN" to "中文 (简体)", + "zh-rTW" to "中文 (繁體)" + ) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/App.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/App.kt new file mode 100644 index 0000000..3e6f0b4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/App.kt @@ -0,0 +1,69 @@ +package dev.brahmkshatriya.echo.di + +import android.app.Application +import android.content.Context.CONNECTIVITY_SERVICE +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.net.Network +import com.mayakapps.kache.FileKache +import com.mayakapps.kache.KacheStrategy +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.NetworkConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +data class App( + val context: Application, + val settings: SharedPreferences, +) { + val throwFlow = MutableSharedFlow() + val messageFlow = MutableSharedFlow() + val scope = CoroutineScope(Dispatchers.IO) + + private suspend fun getCache() = FileKache( + context.cacheDir.resolve("kache").toString(), + 50 * 1024 * 1024 + ) { + strategy = KacheStrategy.LRU + } + + val fileCache = scope.async(Dispatchers.IO, CoroutineStart.LAZY) { + runCatching { getCache() }.getOrElse { + context.cacheDir.resolve("kache").deleteRecursively() + getCache() + } + } + + private val _networkFlow = MutableStateFlow(NetworkConnection.NotConnected) + val networkFlow = _networkFlow.asStateFlow() + val isUnmetered get() = networkFlow.value == NetworkConnection.Unmetered + + init { + scope.launch { + throwFlow.collectLatest { + it.printStackTrace() + } + } + val connectivityManager = + context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + val isMetered = connectivityManager.isActiveNetworkMetered + _networkFlow.value = if (isMetered) NetworkConnection.Metered + else NetworkConnection.Unmetered + } + + override fun onLost(network: Network) { + _networkFlow.value = NetworkConnection.NotConnected + } + } + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/DI.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/DI.kt new file mode 100644 index 0000000..2f05b1d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/di/DI.kt @@ -0,0 +1,92 @@ +package dev.brahmkshatriya.echo.di + +import dev.brahmkshatriya.echo.download.DownloadWorker +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.DownloadDatabase +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.playback.PlayerService +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.download.DownloadViewModel +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoViewModel +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.ui.extensions.add.AddViewModel +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListViewModel +import dev.brahmkshatriya.echo.ui.extensions.login.LoginViewModel +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.main.search.SearchViewModel +import dev.brahmkshatriya.echo.ui.media.MediaViewModel +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.ui.player.more.info.TrackInfoViewModel +import dev.brahmkshatriya.echo.ui.player.more.lyrics.LyricsViewModel +import dev.brahmkshatriya.echo.ui.playlist.create.CreatePlaylistViewModel +import dev.brahmkshatriya.echo.ui.playlist.delete.DeletePlaylistViewModel +import dev.brahmkshatriya.echo.ui.playlist.edit.EditPlaylistViewModel +import dev.brahmkshatriya.echo.ui.playlist.save.SaveToPlaylistViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import org.koin.android.ext.koin.androidApplication +import org.koin.androidx.workmanager.dsl.workerOf +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +object DI { + + private val baseModule = module { + single { androidApplication().getSettings() } + singleOf(::App) + } + + private val extensionModule = module { + includes(baseModule) + singleOf(::ExtensionLoader) + } + + private val downloadModule = module { + includes(extensionModule) + singleOf(DownloadDatabase::create) + singleOf(::Downloader) + workerOf(::DownloadWorker) + } + + private val playerModule = module { + includes(extensionModule) + singleOf(PlayerService::getCache) + single { PlayerState() } + } + + private val uiModules = module { + singleOf(::SnackBarHandler) + viewModelOf(::UiViewModel) + + viewModelOf(::PlayerViewModel) + viewModelOf(::LyricsViewModel) + viewModelOf(::TrackInfoViewModel) + + viewModelOf(::ExtensionsViewModel) + viewModelOf(::ExtensionInfoViewModel) + viewModelOf(::LoginUserListViewModel) + viewModelOf(::AddViewModel) + viewModelOf(::LoginViewModel) + + viewModelOf(::FeedViewModel) + viewModelOf(::SearchViewModel) + viewModelOf(::MediaViewModel) + + viewModelOf(::CreatePlaylistViewModel) + viewModelOf(::DeletePlaylistViewModel) + viewModelOf(::SaveToPlaylistViewModel) + viewModelOf(::EditPlaylistViewModel) + + viewModelOf(::DownloadViewModel) + } + + val appModule = module { + includes(baseModule) + includes(extensionModule) + includes(playerModule) + includes(downloadModule) + includes(uiModules) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/DownloadWorker.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/DownloadWorker.kt new file mode 100644 index 0000000..8113db9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/DownloadWorker.kt @@ -0,0 +1,90 @@ +package dev.brahmkshatriya.echo.download + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.media3.common.util.NotificationUtil +import androidx.media3.common.util.NotificationUtil.createNotificationChannel +import androidx.media3.common.util.UnstableApi +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import dev.brahmkshatriya.echo.MainActivity.Companion.getMainActivity +import dev.brahmkshatriya.echo.R +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class DownloadWorker( + private val context: Context, + params: WorkerParameters, + downloader: Downloader, +) : CoroutineWorker(context, params) { + + private val manager = downloader.taskManager + override suspend fun doWork(): Result { + setForeground(createNotification(0)) + val job = manager.scope.launch { + manager.taskFlow.collectLatest { + if (it.isEmpty()) removeNotification() + else if (isActive) setForeground(createNotification(it.size)) + } + } + manager.awaitCompletion() + job.cancel() + return Result.success() + } + + @OptIn(UnstableApi::class) + private fun createNotification( + tracks: Int, + ): ForegroundInfo { + createNotificationChannel( + context, PROGRESS_CHANNEL_ID, R.string.download_progress, 0, + NotificationUtil.IMPORTANCE_DEFAULT + ) + val notificationBuilder = NotificationCompat.Builder(context, PROGRESS_CHANNEL_ID) + val intent = getMainIntent(context) + + val tracksTitle = runCatching { + context.resources.getQuantityString(R.plurals.number_songs, tracks, tracks) + }.getOrNull() ?: context.getString(R.string.n_songs, tracks) + + return ForegroundInfo( + NOTIF_ID, + notificationBuilder + .setSmallIcon(R.drawable.ic_downloading) + .setContentTitle(context.getString(R.string.downloading_x, tracksTitle)) + .setContentIntent(intent) + .setProgress(100, 0, true) + .setOnlyAlertOnce(true) + .setOngoing(true) + .build(), + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) 0 + else FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } + + private fun removeNotification() { + NotificationManagerCompat.from(context).cancel(NOTIF_ID) + } + + companion object { + private const val NOTIF_ID = 0 + private const val PROGRESS_CHANNEL_ID = "download_progress_channel" + + fun getMainIntent(context: Context) = PendingIntent.getActivity( + context, + 0, + Intent(context, context.getMainActivity()).apply { + putExtra("fromDownload", true) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + )!! + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt new file mode 100644 index 0000000..19c2676 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt @@ -0,0 +1,227 @@ +package dev.brahmkshatriya.echo.download + +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dev.brahmkshatriya.echo.common.clients.DownloadClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.models.DownloadContext +import dev.brahmkshatriya.echo.common.models.Progress +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.db.DownloadDatabase +import dev.brahmkshatriya.echo.download.db.models.ContextEntity +import dev.brahmkshatriya.echo.download.db.models.DownloadEntity +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.download.exceptions.DownloaderExtensionNotFoundException +import dev.brahmkshatriya.echo.download.tasks.TaskManager +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.withExtensionId +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.util.WeakHashMap + +class Downloader( + val app: App, + val extensionLoader: ExtensionLoader, + database: DownloadDatabase, +) { + val unified = extensionLoader.unified.value + + suspend fun downloadExtension() = extensionLoader.misc.value + .find { it.isClient() && it.isEnabled } + ?: throw DownloaderExtensionNotFoundException() + + val scope = CoroutineScope(Dispatchers.IO) + CoroutineName("Downloader") + + val dao = database.downloadDao() + val downloadFlow = dao.getDownloadsFlow() + private val contextFlow = dao.getContextFlow() + private val downloadInfoFlow = downloadFlow.combine(contextFlow) { downloads, contexts -> + downloads.map { download -> + val context = contexts.find { download.contextId == it.id } + Info(download, context, listOf()) + } + } + + val taskManager = TaskManager(this) + + fun add( + downloads: List + ) = scope.launch { + val concurrentDownloads = downloadExtension() + .getAs { concurrentDownloads } + .getOrNull()?.takeIf { it > 0 } ?: 2 + taskManager.setConcurrency(concurrentDownloads) + val contexts = downloads.mapNotNull { it.context }.distinctBy { it.id }.associate { + it.id to dao.insertContextEntity(ContextEntity(0, it.id, it.toJson())) + } + downloads.forEach { + dao.insertDownloadEntity( + DownloadEntity( + 0, + it.track.extras[EXTENSION_ID] ?: it.extensionId, + it.track.id, + contexts[it.context?.id], + it.sortOrder, + it.track.toJson(), + TaskType.Loading, + ) + ) + } + ensureWorker() + } + + private val workManager by lazy { WorkManager.getInstance(app.context) } + private fun ensureWorker() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(Constraints(NetworkType.CONNECTED, requiresStorageNotLow = true)) + .addTag(TAG) + .build() + workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request) + } + + @Suppress("IDENTITY_SENSITIVE_OPERATIONS_WITH_VALUE_TYPE") + private val servers = WeakHashMap() + @Suppress("IDENTITY_SENSITIVE_OPERATIONS_WITH_VALUE_TYPE") + private val mutexes = WeakHashMap() + + suspend fun getServer( + trackId: Long, download: DownloadEntity + ): Streamable.Media.Server = mutexes.getOrPut(trackId) { Mutex() }.withLock { + servers.getOrPut(trackId) { + val extensionId = download.extensionId + val extension = extensionLoader.music.getExtensionOrThrow(extensionId) + val streamable = download.track.getOrThrow() + .streamables.find { it.id == download.streamableId }!! + extension.getAs { + val media = + loadStreamableMedia(streamable, true) as Streamable.Media.Server + media.sources.ifEmpty { + throw Exception("${trackId}: No sources found") + } + media + }.getOrThrow() + } + } + + fun cancel(trackId: Long) { + taskManager.remove(trackId) + scope.launch { + val entity = dao.getDownloadEntity(trackId) ?: return@launch + dao.deleteDownloadEntity(entity) + entity.exceptionFile?.let { + val file = File(it) + if (file.exists()) file.delete() + } + servers.remove(trackId) + mutexes.remove(trackId) + } + } + + fun restart(trackId: Long) { + taskManager.remove(trackId) + scope.launch { + val download = dao.getDownloadEntity(trackId) ?: return@launch + dao.insertDownloadEntity( + download.copy(exceptionFile = null, finalFile = null, fullyDownloaded = false) + ) + download.exceptionFile?.let { + val file = File(it) + if (file.exists()) file.delete() + } + servers.remove(trackId) + mutexes.remove(trackId) + ensureWorker() + } + } + + fun cancelAll() { + taskManager.removeAll() + scope.launch { + val downloads = downloadFlow.first().filter { it.finalFile == null } + downloads.forEach { download -> + dao.deleteDownloadEntity(download) + servers.remove(download.id) + mutexes.remove(download.id) + } + } + } + + fun deleteDownload(id: String) { + scope.launch { + val downloads = downloadFlow.first().filter { it.trackId == id } + downloads.forEach { download -> + dao.deleteDownloadEntity(download) + } + } + } + + fun deleteContext(id: String) { + scope.launch { + val contexts = contextFlow.first().filter { it.itemId == id } + contexts.forEach { context -> + dao.deleteContextEntity(context) + val downloads = downloadFlow.first().filter { + it.contextId == context.id + } + downloads.forEach { download -> + dao.deleteDownloadEntity(download) + } + } + } + } + + data class Info( + val download: DownloadEntity, + val context: ContextEntity?, + val workers: List> + ) + + val flow = downloadInfoFlow.combine(taskManager.progressFlow) { downloads, info -> + downloads.map { (dl, context) -> + val workers = info.filter { it.first.trackId == dl.id }.map { (a, b) -> a.type to b } + Info(dl, context, workers) + }.sortedByDescending { it.workers.size } + }.stateIn(scope, SharingStarted.Eagerly, listOf()) + + init { + scope.launch { + downloadInfoFlow.map { info -> + info.filter { it.download.fullyDownloaded }.groupBy { + it.context?.id + }.flatMap { (id, infos) -> + if (id == null) infos.mapNotNull { + it.download.track.getOrNull() + ?.withExtensionId(it.download.extensionId, false) + } + else listOfNotNull(infos.first().runCatching { + unified.db.getPlaylist(context?.mediaItem!!.getOrThrow()) + }.getOrNull()) + } + }.collect(unified.downloadFeed) + } + } + + companion object { + private const val TAG = "Downloader" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDao.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDao.kt new file mode 100644 index 0000000..22e6772 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDao.kt @@ -0,0 +1,40 @@ +package dev.brahmkshatriya.echo.download.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import dev.brahmkshatriya.echo.download.db.models.ContextEntity +import dev.brahmkshatriya.echo.download.db.models.DownloadEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface DownloadDao { + @Insert(onConflict = REPLACE) + suspend fun insertContextEntity(context: ContextEntity): Long + + @Insert(onConflict = REPLACE) + suspend fun insertDownloadEntity(download: DownloadEntity): Long + + @Query("SELECT * FROM DownloadEntity WHERE id = :trackId") + suspend fun getDownloadEntity(trackId: Long): DownloadEntity? + + @Query("SELECT * FROM ContextEntity WHERE id = :contextId") + suspend fun getContextEntity(contextId: Long?): ContextEntity? + + @Query("SELECT * FROM DownloadEntity") + fun getDownloadsFlow(): Flow> + + @Query("SELECT * FROM ContextEntity") + fun getContextFlow(): Flow> + + @Delete + fun deleteDownloadEntity(download: DownloadEntity) + + @Delete + fun deleteContextEntity(context: ContextEntity) + + @Query("SELECT * FROM DownloadEntity WHERE contextId = :id") + fun getDownloadsForContext(id: Long?): List +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDatabase.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDatabase.kt new file mode 100644 index 0000000..3942b47 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/DownloadDatabase.kt @@ -0,0 +1,27 @@ +package dev.brahmkshatriya.echo.download.db + +import android.app.Application +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import dev.brahmkshatriya.echo.download.db.models.ContextEntity +import dev.brahmkshatriya.echo.download.db.models.DownloadEntity + +@Database( + entities = [ + ContextEntity::class, + DownloadEntity::class, + ], + version = 8, + exportSchema = false +) +abstract class DownloadDatabase : RoomDatabase() { + abstract fun downloadDao(): DownloadDao + + companion object { + private const val DATABASE_NAME = "download-db" + fun create(app: Application) = Room.databaseBuilder( + app, DownloadDatabase::class.java, DATABASE_NAME + ).fallbackToDestructiveMigration(true).build() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/ContextEntity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/ContextEntity.kt new file mode 100644 index 0000000..73a7f87 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/ContextEntity.kt @@ -0,0 +1,16 @@ +package dev.brahmkshatriya.echo.download.db.models + +import androidx.room.Entity +import androidx.room.PrimaryKey +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.utils.Serializer.toData + +@Entity +data class ContextEntity( + @PrimaryKey(true) + val id: Long, + val itemId: String, + val data: String, +) { + val mediaItem by lazy { data.toData() } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/DownloadEntity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/DownloadEntity.kt new file mode 100644 index 0000000..2ebf325 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/DownloadEntity.kt @@ -0,0 +1,41 @@ +package dev.brahmkshatriya.echo.download.db.models + +import androidx.room.Entity +import androidx.room.PrimaryKey +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils +import dev.brahmkshatriya.echo.utils.Serializer.toData +import kotlinx.serialization.Serializable +import java.io.File + +@Entity +@Serializable +data class DownloadEntity( + @PrimaryKey(true) + val id: Long, + val extensionId: String, + val trackId: String, + val contextId: Long?, + val sortOrder: Int? = null, + val data: String, + val task: TaskType, + val loaded: Boolean = false, + val folderPath: String? = null, + val streamableId: String? = null, + val indexesData: String? = null, + val toMergeFilesData: String? = null, + val toTagFile: String? = null, + val finalFile: String? = null, + val exceptionFile: String? = null, + val fullyDownloaded: Boolean = false, +) { + val track by lazy { data.toData() } + val indexes by lazy { indexesData?.toData>()?.getOrNull().orEmpty() } + val toMergeFiles by lazy { toMergeFilesData?.toData>()?.getOrNull().orEmpty() } + val exception by lazy { + runCatching { + exceptionFile?.let { File(it) }?.readText()?.toData()?.getOrThrow() + }.getOrNull() + } + val isFinal by lazy { finalFile != null || exceptionFile != null } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/TaskType.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/TaskType.kt new file mode 100644 index 0000000..81093ac --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/db/models/TaskType.kt @@ -0,0 +1,5 @@ +package dev.brahmkshatriya.echo.download.db.models + +enum class TaskType { + Loading, Downloading, Merging, Tagging, Saving +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloadException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloadException.kt new file mode 100644 index 0000000..d662d75 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloadException.kt @@ -0,0 +1,10 @@ +package dev.brahmkshatriya.echo.download.exceptions + +import dev.brahmkshatriya.echo.download.db.models.DownloadEntity +import dev.brahmkshatriya.echo.download.db.models.TaskType + +data class DownloadException( + val type: TaskType, + val downloadEntity: DownloadEntity, + override val cause: Throwable +) : Exception() \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloaderExtensionNotFoundException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloaderExtensionNotFoundException.kt new file mode 100644 index 0000000..f6c0e80 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/exceptions/DownloaderExtensionNotFoundException.kt @@ -0,0 +1,3 @@ +package dev.brahmkshatriya.echo.download.exceptions + +class DownloaderExtensionNotFoundException : Exception() \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/BaseTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/BaseTask.kt new file mode 100644 index 0000000..4cd31d2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/BaseTask.kt @@ -0,0 +1,117 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.graphics.drawable.Drawable +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.core.graphics.drawable.toBitmap +import androidx.media3.common.util.NotificationUtil +import androidx.media3.common.util.NotificationUtil.createNotificationChannel +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.DownloadClient +import dev.brahmkshatriya.echo.common.models.DownloadContext +import dev.brahmkshatriya.echo.common.models.Progress +import dev.brahmkshatriya.echo.download.DownloadWorker.Companion.getMainIntent +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.download.exceptions.DownloadException +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.toData +import dev.brahmkshatriya.echo.utils.CoroutineUtils.throttleLatest +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import java.io.File + +abstract class BaseTask( + private val context: Context, + val downloader: Downloader, + open val trackId: Long, +) { + abstract val type: TaskType + val progressFlow = MutableStateFlow(Progress()) + val throttledProgressFlow = progressFlow.throttleLatest(500L) + val running = MutableStateFlow(false) + suspend fun withDownloadExtension(block: suspend DownloadClient.() -> T) = + downloader.downloadExtension().getAs { block() }.getOrThrow() + + val dao = downloader.dao + + suspend fun doWork() = withContext(Dispatchers.IO) { + running.value = true + val result = runCatching { work(trackId) } + val throwable = result.exceptionOrNull() + val download = dao.getDownloadEntity(trackId) + if (throwable != null && download != null) { + val exception = DownloadException(type, download, throwable) + val exceptionFile = context.exceptionDir().resolve("$trackId.json") + exceptionFile.writeText(exception.toData(context).toJson()) + dao.insertDownloadEntity(download.copy(exceptionFile = exceptionFile.absolutePath)) + } + running.value = false + result + } + + suspend fun getDownload() = dao.getDownloadEntity(trackId)!! + + suspend fun getDownloadContext() = run { + val download = getDownload() + val contextEntity = download.contextId?.let { dao.getContextEntity(it) } + DownloadContext( + download.extensionId, + download.track.getOrThrow(), + download.sortOrder, + contextEntity?.mediaItem?.getOrThrow() + ) + } + + abstract suspend fun work(trackId: Long) + + companion object { + fun Context.getTitle(type: TaskType, title: String) = when (type) { + TaskType.Loading -> getString(R.string.loading_x, title) + TaskType.Downloading -> getString(R.string.downloading_x, title) + TaskType.Merging -> getString(R.string.merging_x, title) + TaskType.Tagging -> getString(R.string.tagging_x, title) + TaskType.Saving -> getString(R.string.saving_x, title) + } + + private const val DOWNLOAD_CHANNEL_ID = "download_channel" + + fun Context.exceptionDir() = File(filesDir, "download_exceptions").apply { mkdirs() } + } + + + @OptIn(UnstableApi::class) + fun createCompleteNotification( + context: Context, + title: String, + drawable: Drawable?, + ) { + createNotificationChannel( + context, DOWNLOAD_CHANNEL_ID, R.string.download_complete, 0, + NotificationUtil.IMPORTANCE_DEFAULT + ) + val notificationBuilder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_downloading) + .setContentTitle(context.getString(R.string.download_complete)) + .setContentText(title) + .setStyle( + NotificationCompat.BigPictureStyle().bigLargeIcon(drawable?.toBitmap()) + ) + .setContentIntent(getMainIntent(context)) + .setAutoCancel(true) + + if (checkSelfPermission(context, POST_NOTIFICATIONS) != PERMISSION_GRANTED) return + NotificationManagerCompat.from(context).notify( + title.hashCode(), + notificationBuilder.build() + ) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/DownloadingTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/DownloadingTask.kt new file mode 100644 index 0000000..9cb036c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/DownloadingTask.kt @@ -0,0 +1,27 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.content.Context +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.utils.Serializer.toJson + +class DownloadingTask( + context: Context, + downloader: Downloader, + override val trackId: Long, + val index: Int, +) : BaseTask(context, downloader, trackId) { + + override val type = TaskType.Downloading + override suspend fun work(trackId: Long) { + var download = getDownload() + val server = downloader.getServer(trackId, download) + val source = server.sources[index] + val downloadContext = getDownloadContext() + val file = withDownloadExtension { download(progressFlow, downloadContext, source) } + download = getDownload() + download = + download.copy(toMergeFilesData = (download.toMergeFiles + file.toString()).toJson()) + dao.insertDownloadEntity(download) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/LoadingTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/LoadingTask.kt new file mode 100644 index 0000000..21e71de --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/LoadingTask.kt @@ -0,0 +1,70 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.models.Progress +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.download.tasks.TaskManager.Companion.toQueueItem +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.utils.Serializer.toJson + +class LoadingTask( + private val context: Context, + downloader: Downloader, + override val trackId: Long, +) : BaseTask(context, downloader, trackId) { + + override val type = TaskType.Loading + + private val manager = downloader.taskManager + private val extensionsList = downloader.extensionLoader.music + + private val totalSize = 3L + + override suspend fun work(trackId: Long) { + progressFlow.value = Progress(totalSize, 0) + var download = dao.getDownloadEntity(trackId)!! + val extension = extensionsList.getExtensionOrThrow(download.extensionId) + if (!download.loaded) { + val track = extension.getAs { + loadTrack(download.track.getOrThrow(), true) + }.getOrThrow() + track.servers.ifEmpty { throw Exception("${track.title}: No servers found") } + download = download.copy(data = track.toJson(), loaded = true) + dao.insertDownloadEntity(download) + } + + progressFlow.value = Progress(totalSize, 1) + val downloadContext = getDownloadContext() + if (download.streamableId == null) { + val selected = withDownloadExtension { selectServer(downloadContext) } + download = download.copy(streamableId = selected.id) + dao.insertDownloadEntity(download) + } + progressFlow.value = Progress(totalSize, 2) + + val server = downloader.getServer(trackId, download) + + val indexes = download.indexes.ifEmpty { + val sources = withDownloadExtension { selectSources(downloadContext, server) } + sources.map { server.sources.indexOf(it) } + } + if (indexes.isEmpty()) throw Exception("No files to download") + download = download.copy(indexesData = indexes.toJson()) + dao.insertDownloadEntity(download) + + progressFlow.value = Progress(totalSize, 3) + + val requests = indexes.map { index -> + DownloadingTask(context, downloader, trackId, index) + }.toQueueItem() + val mergeRequest = MergingTask(context, downloader, trackId).toQueueItem() + val taggingRequest = TaggingTask(context, downloader, trackId).toQueueItem() + val saveToUnified = SaveToUnifiedTask(context, downloader, trackId).toQueueItem() + + manager.enqueue(trackId, listOf(requests, mergeRequest, taggingRequest, saveToUnified)) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/MergingTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/MergingTask.kt new file mode 100644 index 0000000..b96828f --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/MergingTask.kt @@ -0,0 +1,24 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.content.Context +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import java.io.File + +class MergingTask( + context: Context, + downloader: Downloader, + override val trackId: Long, +) : BaseTask(context, downloader, trackId) { + override val type = TaskType.Merging + + override suspend fun work(trackId: Long) { + val downloadContext = getDownloadContext() + val download = getDownload() + val file = withDownloadExtension { + merge(progressFlow, downloadContext, download.toMergeFiles.map { File(it) }) + } + dao.insertDownloadEntity(download.copy(toTagFile = file.toString())) + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/SaveToUnifiedTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/SaveToUnifiedTask.kt new file mode 100644 index 0000000..e37c007 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/SaveToUnifiedTask.kt @@ -0,0 +1,59 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.models.Feed.Companion.loadAll +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.withExtensionId +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadDrawable + +class SaveToUnifiedTask( + private val app: Context, + downloader: Downloader, + override val trackId: Long, +) : BaseTask(app, downloader, trackId) { + + override val type = TaskType.Saving + + override suspend fun work(trackId: Long) { + val old = getDownload() + if (old.finalFile == null) return + + val download = old.copy( + data = old.track.getOrThrow().withExtensionId(old.extensionId, false).toJson(), + ) + dao.insertDownloadEntity(download) + + val downloadContext = getDownloadContext() + val context = downloadContext.context + val allDownloads = dao.getDownloadsForContext(download.contextId) + + val unifiedExtension = + downloader.extensionLoader.music.getExtensionOrThrow(UnifiedExtension.metadata.id) + + if (context != null) unifiedExtension.getAs { + val db = (this as UnifiedExtension).db + val playlist = db.getOrCreate(app, context) + val tracks = loadTracks(playlist).loadAll() + if (allDownloads.all { it.finalFile != null }) { + removeTracksFromPlaylist(playlist, tracks, tracks.indices.toList()) + val sorted = allDownloads.sortedBy { it.sortOrder } + addTracksToPlaylist(playlist, listOf(), 0, sorted.map { it.track.getOrThrow() }) + } else addTracksToPlaylist( + playlist, tracks, tracks.size, listOf(download.track.getOrThrow()) + ) + }.getOrThrow() + + dao.insertDownloadEntity(download.copy(fullyDownloaded = true)) + + val item = if (context == null) download.track.getOrThrow() else { + if (allDownloads.all { it.finalFile != null }) context else null + } ?: return + createCompleteNotification(app, item.title, item.cover.loadDrawable(app)) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaggingTask.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaggingTask.kt new file mode 100644 index 0000000..ebff7c9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaggingTask.kt @@ -0,0 +1,29 @@ +package dev.brahmkshatriya.echo.download.tasks + +import android.content.Context +import android.media.MediaScannerConnection +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import java.io.File + +class TaggingTask( + private val app: Context, + downloader: Downloader, + override val trackId: Long, +) : BaseTask(app, downloader, trackId) { + + override val type = TaskType.Tagging + + override suspend fun work(trackId: Long) { + val downloadContext = getDownloadContext() + val download = getDownload() + val file = withDownloadExtension { + tag(progressFlow, downloadContext, File(download.toTagFile!!)) + } + dao.insertDownloadEntity(download.copy(finalFile = file.toString())) + MediaScannerConnection.scanFile( + app, arrayOf(file.toString()), null, null + ) + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaskManager.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaskManager.kt new file mode 100644 index 0000000..c7ca4b4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/download/tasks/TaskManager.kt @@ -0,0 +1,101 @@ +package dev.brahmkshatriya.echo.download.tasks + +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.TaskType +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +class TaskManager(private val downloader: Downloader) { + val scope = downloader.scope + private val downloadFlow = downloader.downloadFlow + private val context = downloader.app.context + + val taskFlow = MutableStateFlow(listOf()) + private var taskSemaphores = TaskType.entries.associateWith { Semaphore(2) } + fun setConcurrency(limit: Int) { + taskSemaphores = TaskType.entries.associateWith { Semaphore(limit) } + } + + val progressFlow = channelFlow { + taskFlow.map { items -> + items.flatMap { it.queue }.flatMap { it.tasks } + }.collectLatest { tasks -> + if (tasks.isEmpty()) send(emptyArray()) + else combine(tasks.map { it.running }) { _ -> + tasks.filter { it.running.value }.map { task -> + task.throttledProgressFlow.map { task to it } + } + }.collectLatest { progressFlows -> + if (progressFlows.isEmpty()) send(emptyArray()) + else combine(progressFlows) { it }.collectLatest { send(it) } + } + } + }.stateIn(scope, SharingStarted.Eagerly, arrayOf()) + + data class TaskItem(val trackId: Long, val queue: List, val job: Job) + data class QueueItem(val tasks: List) + + companion object { + fun BaseTask.toQueueItem() = QueueItem(listOf(this)) + fun List.toQueueItem() = QueueItem(this) + } + + fun enqueue(trackId: Long, items: List) { + val list = taskFlow.value.toMutableList() + list.add(TaskItem(trackId, items, scope.launch { + runCatching { + items.onEach { item -> + val results = item.tasks.map { worker -> + scope.async { + taskSemaphores[worker.type]!!.withPermit { worker.doWork() } + } + }.awaitAll() + results.onEach { it.getOrThrow() } + } + } + val new = taskFlow.value.toMutableList() + new.removeAll { it.queue == items } + taskFlow.value = new + })) + taskFlow.value = list + } + + private fun enqueueLoadingWork(trackId: Long) { + val loadingWorker = LoadingTask(context, downloader, trackId) + enqueue(trackId, listOf(QueueItem(listOf(loadingWorker)))) + } + + suspend fun awaitCompletion() { + downloadFlow.map { entities -> entities.filter { !it.isFinal } }.first { entities -> + val works = taskFlow.value.map { it.trackId } + val notStarted = entities.filter { !works.contains(it.id) } + notStarted.forEach { enqueueLoadingWork(it.id) } + entities.isEmpty() + } + } + + fun remove(trackId: Long) { + val list = taskFlow.value.toMutableList() + val works = list.filter { it.trackId == trackId } + works.forEach { it.job.cancel() } + list.removeAll(works) + taskFlow.value = list + } + + fun removeAll() { + taskFlow.value.forEach { it.job.cancel() } + taskFlow.value = listOf() + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt new file mode 100644 index 0000000..a8afeee --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt @@ -0,0 +1,264 @@ +package dev.brahmkshatriya.echo.extensions + +import androidx.annotation.OptIn +import androidx.core.content.edit +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.LyricsExtension +import dev.brahmkshatriya.echo.common.MiscExtension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.TrackerExtension +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.common.helpers.Injectable +import dev.brahmkshatriya.echo.common.helpers.WebViewClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.providers.GlobalSettingsProvider +import dev.brahmkshatriya.echo.common.providers.LyricsExtensionsProvider +import dev.brahmkshatriya.echo.common.providers.MessageFlowProvider +import dev.brahmkshatriya.echo.common.providers.MetadataProvider +import dev.brahmkshatriya.echo.common.providers.MiscExtensionsProvider +import dev.brahmkshatriya.echo.common.providers.MusicExtensionsProvider +import dev.brahmkshatriya.echo.common.providers.NetworkConnectionProvider +import dev.brahmkshatriya.echo.common.providers.TrackerExtensionsProvider +import dev.brahmkshatriya.echo.common.providers.WebViewClientProvider +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.get +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.inject +import dev.brahmkshatriya.echo.extensions.builtin.offline.OfflineExtension +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension +import dev.brahmkshatriya.echo.extensions.db.ExtensionDatabase +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import dev.brahmkshatriya.echo.extensions.exceptions.RequiredExtensionsMissingException +import dev.brahmkshatriya.echo.extensions.repo.CombinedRepository +import dev.brahmkshatriya.echo.extensions.repo.ExtensionParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.io.File + +@OptIn(UnstableApi::class) +class ExtensionLoader( + val app: App, + val cache: SimpleCache, +) { + val parser = ExtensionParser(app.context) + val scope = CoroutineScope(Dispatchers.IO) + val db = ExtensionDatabase.create(app.context) + + private var permGrantedFlow = false + fun setPermGranted() { + if (permGrantedFlow) return + permGrantedFlow = true + val id = current.value?.id + if (id == OfflineExtension.metadata.id || id == UnifiedExtension.metadata.id) { + current.value = null + scope.launch { + delay(1) + setCurrentExtension() + } + } + } + + val unified = lazy { UnifiedExtension(app, cache) } + val fileIgnoreFlow = MutableSharedFlow() + private val repository = CombinedRepository( + scope, app.context, fileIgnoreFlow, parser, + UnifiedExtension.metadata to unified, + OfflineExtension.metadata to lazy { OfflineExtension(app.context) }, +// TestExtension.metadata to lazy { TestExtension() }, +// DownloadExtension.metadata to lazy { DownloadExtension(app.context) } +// TrackerTestExtension.metadata to Injectable { TrackerTestExtension() }, + ) + + private val settings = app.settings + val priorityMap = ExtensionType.entries.associateWith { + val key = it.priorityKey() + val list = settings.getString(key, null).orEmpty().split(',') + MutableStateFlow(list) + } + + val current = MutableStateFlow(null) + private fun setCurrentExtension() { + val last = settings.getString(LAST_EXTENSION_KEY, null) + val list = music.value + val extension = list.find { it.id == last && it.isEnabled } + ?: list.firstOrNull { it.isEnabled } + ?: return + setupMusicExtension(extension, false) + } + + fun setupMusicExtension(extension: MusicExtension, manual: Boolean) { + if (manual) settings.edit { putString(LAST_EXTENSION_KEY, extension.id) } + current.value = extension + scope.launch { + extension.get { onExtensionSelected() }.getOrThrow(app.throwFlow) + } + } + + private val injected = repository.flow.map { list -> + list?.groupBy { it.getOrNull()?.first?.run { type to id } }?.map { entry -> + entry.value.minBy { it.getOrNull()?.first?.importType?.ordinal ?: Int.MAX_VALUE } + }.orEmpty() + }.combine(db.extensionEnabledFlow) { list, enabledList -> + val enabledMap = enabledList.associate { (it.type to it.id) to it.enabled } + list.map { result -> + result.mapCatching { (metadata, injectable) -> + val key = metadata.run { type to id } + val isEnabled = enabledMap[key] ?: metadata.isEnabled + metadata.copy(isEnabled = isEnabled) to injectable + } + } + }.map { list -> + list.map { result -> + result.map { + it.first to it.second.injected(it.first) + } + } + }.combine(db.currentUsersFlow) { list, users -> + list.onEach { result -> + scope.launch(Dispatchers.IO) { + val (metadata, injectable) = result.getOrNull() ?: return@launch + runCatching { + injectable.injectOrRun("user") { + if (this !is LoginClient) return@injectOrRun + val newCurr = users.getUser(metadata) + val user = newCurr?.let { db.getUser(it) } + setLoginUser(user) + } + }.onFailure { + app.throwFlow.emit(it.toAppException(metadata)) + } + } + } + } + + val webViewClientFactory = WebViewClientFactory(app.context) + private fun createWebClient(metadata: Metadata): WebViewClient { + if (metadata.type != ExtensionType.MUSIC) + throw Exception("Webview client is not available for ${metadata.type} Extensions") + return webViewClientFactory.createFor(metadata) + } + + private fun Lazy.injected( + metadata: Metadata, + ) = Injectable(::value, mutableListOf({ + if (this is MetadataProvider) setMetadata(metadata) + if (this is MessageFlowProvider) setMessageFlow(app.messageFlow) + if (this is GlobalSettingsProvider) + setGlobalSettings(ExtensionUtils.getGlobalSettings(app.context)) + setSettings(ExtensionUtils.getSettings(app.context, metadata)) + if (this is WebViewClientProvider) setWebViewClient(createWebClient(metadata)) + onInitialize() + onExtensionSelected() + })) + + private fun > mapped( + type: ExtensionType, transform: (Metadata, Injectable) -> T, + ) = injected.map { list -> + list.mapNotNull { + val (meta, injectable) = it.getOrNull() ?: return@mapNotNull null + if (meta.type != type) return@mapNotNull null + transform(meta, injectable) + } + }.combine(priorityMap[type]!!) { list, _ -> + list.sorted(type) { it.id } + }.stateIn(scope, SharingStarted.Lazily, emptyList()) + + val music = mapped(ExtensionType.MUSIC) { m, i -> MusicExtension(m, i) } + val tracker = mapped(ExtensionType.TRACKER) { m, i -> TrackerExtension(m, i.casted()) } + val lyrics = mapped(ExtensionType.LYRICS) { m, i -> LyricsExtension(m, i.casted()) } + val misc = mapped(ExtensionType.MISC) { m, i -> MiscExtension(m, i) } + val all = combine(music, tracker, lyrics, misc) { music, tracker, lyrics, misc -> + music + tracker + lyrics + misc + }.stateIn(scope, SharingStarted.Lazily, emptyList()) + + init { + scope.launch { + all.collect { list -> + list.forEach { + if (!it.isEnabled) return@forEach + it.inject("providers", app.throwFlow) { injectProviders(this) } + } + } + } + scope.launch { + music.collectLatest { setCurrentExtension() } + } + scope.launch { + app.networkFlow.combine(all) { a, b -> a to b }.collect { (conn, all) -> + all.forEach { + if (!it.isEnabled) return@forEach + it.inject("network", app.throwFlow) { + if (this !is NetworkConnectionProvider) return@inject + setNetworkConnection(conn) + } + } + } + } + } + + private fun List.sorted(type: ExtensionType, id: (T) -> String): List { + val priority = priorityMap[type]!!.value + return sortedBy { priority.indexOf(id(it)) } + } + + fun getFlow(type: ExtensionType) = when (type) { + ExtensionType.MUSIC -> music + ExtensionType.TRACKER -> tracker + ExtensionType.LYRICS -> lyrics + ExtensionType.MISC -> misc + } + + private fun injectProviders(client: ExtensionClient) { + (client as? MusicExtensionsProvider)?.run { + inject(requiredMusicExtensions, music.value) { setMusicExtensions(it) } + } + (client as? TrackerExtensionsProvider)?.run { + inject(requiredTrackerExtensions, tracker.value) { setTrackerExtensions(it) } + } + (client as? LyricsExtensionsProvider)?.run { + inject(requiredLyricsExtensions, lyrics.value) { setLyricsExtensions(it) } + } + (client as? MiscExtensionsProvider)?.run { + inject(requiredMiscExtensions, misc.value) { setMiscExtensions(it) } + } + } + + companion object { + private fun > T.inject( + required: List, + extensions: List, + set: T.(List) -> Unit, + ) { + if (required.isEmpty()) set(extensions) + else { + val filtered = extensions.filter { it.metadata.id in required } + if (filtered.size == required.size) set(filtered) + else throw RequiredExtensionsMissingException(required) + } + } + + fun List.getUser(ext: Metadata): CurrentUser? { + val curr = find { it.type == ext.type && it.extId == ext.id } + return curr + } + + fun ExtensionType.priorityKey() = "priority_${this.feature}" + + const val LAST_EXTENSION_KEY = "last_extension" + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt new file mode 100644 index 0000000..20b9a3c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionUtils.kt @@ -0,0 +1,145 @@ +package dev.brahmkshatriya.echo.extensions + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.helpers.ClientException +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import dev.brahmkshatriya.echo.extensions.exceptions.ExtensionNotFoundException +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +object ExtensionUtils { + + suspend fun Extension<*>.get( + block: suspend ExtensionClient.() -> R + ) = runCatching { + runCatching { + withContext(Dispatchers.IO) { instance.value().getOrThrow().block() } + }.getOrElse { throw it.toAppException(this) } + } + + suspend inline fun Extension<*>.getAs( + crossinline block: suspend C.() -> R + ): Result = get { + val client = this as? C + ?: throw ClientException.NotSupported(C::class.simpleName ?: "Unknown Class") + block(client) + } + + suspend inline fun Extension<*>.getIf( + crossinline block: suspend C.() -> R + ): Result = get { + val client = this as? C + client?.let { block(it) } + } + + suspend fun Result.getOrThrow( + throwableFlow: MutableSharedFlow + ) = getOrElse { + throwableFlow.emit(it) + it.printStackTrace() + null + } + + suspend inline fun Extension<*>.runIf( + throwableFlow: MutableSharedFlow, + crossinline block: suspend C.() -> Unit + ) { getIf(block).getOrThrow(throwableFlow) } + + suspend inline fun Extension<*>.getIf( + throwableFlow: MutableSharedFlow, + crossinline block: suspend C.() -> R + ): R? = getIf(block).getOrThrow(throwableFlow) + + suspend fun Extension<*>.inject( + id: String, + throwableFlow: MutableSharedFlow, + block: suspend ExtensionClient.() -> Unit + ) = runCatching { + instance.injectOrRun(id) { withContext(Dispatchers.IO) { block() } } + }.getOrElse { + throwableFlow.emit(it.toAppException(this)) + } + + suspend inline fun Extension<*>.isClient() = instance.value().getOrNull() is T + + suspend fun > Flow>.getExtension(id: String?): T? { + val list = first { it.isNotEmpty() } + return list.find { it.id == id } + } + + suspend fun > Flow>.getExtensionOrThrow(id: String?) = + getExtension(id) ?: throw ExtensionNotFoundException(id) + + fun > Flow>.getExtensionFlow(id: String?): Flow { + return map { list -> list.find { it.id == id } } + } + + fun extensionPrefId(extensionType: String, extensionId: String) = "$extensionType-$extensionId" + fun String.prefs(context: Context) = + context.getSharedPreferences(this, Context.MODE_PRIVATE)!! + + fun Metadata.prefs(context: Context) = extensionPrefId(type.name, id).prefs(context) + fun Extension<*>.prefs(context: Context) = metadata.prefs(context) + + fun getSettings(context: Context, metadata: Metadata): Settings { + return toSettings(metadata.prefs(context)) + } + + fun getGlobalSettings(context: Context): Settings { + return toSettings(context.getSettings()) + } + + fun toSettings(prefs: SharedPreferences) = object : Settings { + override fun getString(key: String) = prefs.getString(key, null) + override fun putString(key: String, value: String?) { + prefs.edit { putString(key, value) } + } + + override fun getInt(key: String) = + if (prefs.contains(key)) prefs.getInt(key, 0) else null + + override fun putInt(key: String, value: Int?) { + prefs.edit { if (value != null) putInt(key, value) else remove(key) } + } + + override fun getBoolean(key: String) = + if (prefs.contains(key)) prefs.getBoolean(key, false) else null + + override fun putBoolean(key: String, value: Boolean?) { + prefs.edit { if (value != null) putBoolean(key, value) else remove(key) } + } + + override fun getStringSet(key: String) = prefs.getStringSet(key, null) + override fun putStringSet(key: String, value: Set?) { + prefs.edit { putStringSet(key, value) } + } + } + + @Suppress("UNCHECKED_CAST") + fun SharedPreferences.copyTo(dest: SharedPreferences) = dest.edit { + all.entries.forEach { entry -> + val value = entry.value ?: return@forEach + val key = entry.key + when (value) { + is String -> putString(key, value) + is Set<*> -> putStringSet(key, value as Set) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + is Boolean -> putBoolean(key, value) + else -> {} + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt new file mode 100644 index 0000000..19ec299 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt @@ -0,0 +1,114 @@ +package dev.brahmkshatriya.echo.extensions + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import dev.brahmkshatriya.echo.extensions.repo.ExtensionParser.Companion.PACKAGE_FLAGS +import dev.brahmkshatriya.echo.extensions.repo.FileRepository.Companion.getExtensionsFileDir +import dev.brahmkshatriya.echo.utils.ContextUtils.getTempFile +import dev.brahmkshatriya.echo.utils.PermsUtils.registerActivityResultLauncher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.resume + +object InstallationUtils { + + suspend fun installApp(activity: FragmentActivity, file: File) { + val contentUri = FileProvider.getUriForFile( + activity, "${activity.packageName}.provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + data = contentUri + } + val it = activity.waitForResult(installIntent) + if (it.resultCode == Activity.RESULT_OK) return + val result = it.data?.extras?.getInt("android.intent.extra.INSTALL_RESULT") + throw Exception("Please uninstall the existing extension first. Error Code: $result") + } + + suspend fun installFile( + context: Context, fileIgnoreFlow: MutableSharedFlow, id: String, tempFile: File + ) { + val dir = context.getExtensionsFileDir() + val newFile = File(dir, "$id.apk") + dir.setWritable(true) + newFile.setWritable(true) + if (newFile.exists()) + if (!newFile.delete()) + throw IllegalStateException("Failed to delete existing file: $newFile") + tempFile.renameTo(newFile) + newFile.setWritable(false) + dir.setReadOnly() + fileIgnoreFlow.emit(null) + } + + suspend fun FragmentActivity.openFileSelector( + fileType: String = "application/octet-stream" + ): File { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = fileType + addCategory(Intent.CATEGORY_OPENABLE) + } + val result = waitForResult(intent) + val uri = result.data?.data ?: throw IllegalStateException("No file selected") + return getTempFile(uri) + } + + fun Context.getTempFile(uri: Uri): File { + val stream = contentResolver.openInputStream(uri)!! + val tempFile = getTempFile("dat") + tempFile.outputStream().use { outputStream -> + stream.copyTo(outputStream) + } + return tempFile + } + + suspend fun uninstallApp(activity: FragmentActivity, path: String) { + val packageName = + activity.packageManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)?.packageName + ?: throw IllegalStateException("Invalid APK path or package name not found") + activity.packageManager.getPackageInfo(packageName, 0) + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + val result = activity.waitForResult(intent) + if (result.resultCode != Activity.RESULT_OK) { + val errorCode = result.data?.extras?.getInt("android.intent.extra.INSTALL_RESULT") + throw Exception("Failed to uninstall extension: $errorCode") + } + } + + suspend fun uninstallFile( + fileIgnoreFlow: MutableSharedFlow, path: String + ) = withContext(Dispatchers.IO) { + val file = File(path) + fileIgnoreFlow.emit(file) + file.parentFile!!.setWritable(true) + file.setWritable(true) + if (file.exists() && !file.delete()) + throw IllegalStateException("Failed to delete file: $file") + fileIgnoreFlow.emit(null) + } + + private suspend fun FragmentActivity.waitForResult( + intent: Intent + ) = suspendCancellableCoroutine { cont -> + val contract = ActivityResultContracts.StartActivityForResult() + val launcher = registerActivityResultLauncher(contract) { cont.resume(it) } + cont.invokeOnCancellation { launcher.unregister() } + launcher.launch(intent) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/MediaState.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/MediaState.kt new file mode 100644 index 0000000..7ed432e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/MediaState.kt @@ -0,0 +1,34 @@ +package dev.brahmkshatriya.echo.extensions + +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MediaState { + val extensionId: String + val item: T + val loaded: Boolean + + @Serializable + class Loaded( + override val extensionId: String, + override val item: T, + val isFollowed: Boolean?, + val followers: Long?, + val isSaved: Boolean?, + val isLiked: Boolean?, + val isHidden: Boolean?, + val showRadio: Boolean, + val showShare: Boolean + ) : MediaState { + override val loaded = true + } + + @Serializable + data class Unloaded( + override val extensionId: String, + override val item: T + ) : MediaState { + override val loaded = false + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/WebViewClientFactory.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/WebViewClientFactory.kt new file mode 100644 index 0000000..539fbe3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/WebViewClientFactory.kt @@ -0,0 +1,58 @@ +package dev.brahmkshatriya.echo.extensions + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import dev.brahmkshatriya.echo.MainActivity.Companion.getMainActivity +import dev.brahmkshatriya.echo.common.helpers.WebViewClient +import dev.brahmkshatriya.echo.common.helpers.WebViewRequest +import dev.brahmkshatriya.echo.common.models.Metadata +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first + +class WebViewClientFactory( + private val context: Context, +) { + + val requests = mutableMapOf() + val responseFlow = MutableSharedFlow?>>() + + suspend fun await( + ext: Metadata, showWebView: Boolean, reason: String, request: WebViewRequest, + ): Result { + val wrapper = Wrapper(ext, showWebView, reason, request) + val id = wrapper.hashCode() + requests[id] = wrapper + startWebView(id) + val res = responseFlow.first { it.first == wrapper && it.second != null }.second!! + requests.remove(id) + return res + } + + private fun startWebView(id: Int) { + PendingIntent.getActivity( + context, + 0, + Intent(context, context.getMainActivity()).apply { + putExtra("webViewRequest", id) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ).send() + } + + fun createFor(metadata: Metadata) = object : WebViewClient { + override suspend fun await( + showWebView: Boolean, + reason: String, + request: WebViewRequest, + ): Result = await(metadata, showWebView, reason, request) + } + + data class Wrapper( + val extension: Metadata, + val showWebView: Boolean, + val reason: String, + val request: WebViewRequest, + ) + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/Convertors.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/Convertors.kt new file mode 100644 index 0000000..3862c4d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/Convertors.kt @@ -0,0 +1,94 @@ +package dev.brahmkshatriya.echo.extensions.builtin.offline + +import android.content.Context +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.Date +import dev.brahmkshatriya.echo.common.models.Date.Companion.toYearDate +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeed +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceImageHolder +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceUriImageHolder +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID + +fun MediaStoreUtils.MAlbum.toAlbum() = Album( + id.toString(), + title ?: "Unknown", + null, + cover.toString().toResourceUriImageHolder(), + artists.map { it.toArtist() }, + songList.size.toLong(), + songList.sumOf { it.duration ?: 0 }, + albumYear?.toYearDate(), + extras = mapOf(EXTENSION_ID to OfflineExtension.metadata.id) +) + +fun MediaStoreUtils.MArtist?.toArtist() = Artist( + this?.id.toString(), + this?.title ?: "Unknown", + this?.songList?.firstOrNull()?.cover, + extras = mapOf(EXTENSION_ID to OfflineExtension.metadata.id) +) + +fun MediaStoreUtils.MPlaylist.toPlaylist() = Playlist( + id.toString(), + title ?: "Unknown", + true, + isPrivate = true, + cover = songList.firstOrNull()?.cover, + authors = listOf(), + trackCount = songList.size.toLong(), + duration = songList.sumOf { it.duration ?: 0 }, + creationDate = modifiedDate.toDate(), + description = description, + extras = mapOf(EXTENSION_ID to OfflineExtension.metadata.id) +) + +fun Long.toDate() = Date(this) + +private fun MediaStoreUtils.FileNode.toSongList(): List = + songList + folderList.values.flatMap { it.toSongList() } + +fun MediaStoreUtils.FileNode.toShelf( + context: Context, + title: String? +): Shelf.Category = run { + if (folderList.size == 1 && songList.isEmpty()) + return@run folderList.entries.first() + .run { value.toShelf(context, "${title ?: folderName}/$key") } + val itemSize = folderList.size + songList.size + Shelf.Category( + folderName, + title ?: folderName, + PagedData.Single { + folderList.map { + it.value.toShelf(context, it.key) + } + songList.map { it.toShelf() } + }.toFeed( + Feed.Buttons( + showPlayAndShuffle = true, + customTrackList = toSongList() + ), + songList.firstOrNull()?.cover + ), + context.resources.runCatching { + getQuantityString(R.plurals.number_items, itemSize, itemSize) + }.getOrNull() ?: context.getString(R.string.n_items, itemSize), + image = songList.firstOrNull()?.cover ?: R.drawable.ic_offline_files.toResourceImageHolder() + ) +} + +fun MediaStoreUtils.Genre.toShelf(): Shelf { + val id = id.toString() + return Shelf.Lists.Tracks( + id, + title ?: "Unknown", + songList.take(9), + more = PagedData.Single { songList.map { it.toShelf() } }.toFeed() + ) +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/MediaStoreUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/MediaStoreUtils.kt new file mode 100644 index 0000000..d8b223f --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/MediaStoreUtils.kt @@ -0,0 +1,743 @@ +package dev.brahmkshatriya.echo.extensions.builtin.offline + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.database.getIntOrNull +import androidx.core.database.getLongOrNull +import androidx.core.database.getStringOrNull +import androidx.core.net.toUri +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.Date.Companion.toYearDate +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceUriImageHolder +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.getAllSongs +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Blatantly kanged from [Gramophone](https://github.com/AkaneTan/Gramophone/blob/beta/app/src/main/kotlin/org/akanework/gramophone/logic/utils/MediaStoreUtils.kt) + */ +object MediaStoreUtils { + + private const val TAG = "MediaStoreUtils" + + // settings key used by OfflineExtension; contains comma-separated names that should not + // be treated as separate artists even if they contain commas or ampersands. + private const val ARTIST_EXCLUSIONS = "artist_exclusions" + + interface Item { + val id: Long? + val title: String? + val songList: MutableSet + } + + /** + * [MAlbum] stores Album metadata. + */ + interface MAlbum : Item { + override val id: Long? + override val title: String? + val artists: List + val albumYear: Int? + val cover: Uri? + override val songList: MutableSet + } + + data class AlbumImpl( + override val id: Long?, + override val title: String?, + override val artists: List, + override val albumYear: Int?, + override var cover: Uri?, + override val songList: MutableSet, + ) : MAlbum + + /** + * [MArtist] stores Artist metadata. + */ + data class MArtist( + override val id: Long?, + override val title: String?, + override val songList: MutableSet, + val albumList: MutableList, + ) : Item + + /** + * [Genre] stores Genre metadata. + */ + data class Genre( + override val id: Long?, + override val title: String?, + override val songList: MutableSet, + ) : Item + + /** + * [Date] stores Date metadata. + */ + data class Date( + override val id: Long, + override val title: String?, + override val songList: MutableSet, + ) : Item + + /** + * [MPlaylist] stores playlist information. + */ + open class MPlaylist( + override val id: Long, + override val title: String?, + override val songList: MutableSet, + val description: String?, + val modifiedDate: Long, + ) : Item + + + /** + * [LibraryStoreClass] collects above metadata classes + * together for more convenient reading/writing. + */ + @Suppress("unused") + class LibraryStoreClass( + val songList: MutableList, + val albumList: MutableList, + val artistMap: MutableMap, + val genreList: MutableList, + val dateList: MutableList, + val playlistList: MutableList, + val likedPlaylist: MPlaylist?, + val folderStructure: FileNode, + val shallowFolder: FileNode, + val folders: Set, + ) + + class FileNode( + val folderName: String, + ) { + val folderList = hashMapOf() + val songList = mutableListOf() + var albumId: Long? = null + private set + + fun addSong(item: Track, id: Long?) { + if (albumId != null && id != albumId) { + albumId = null + } else if (albumId == null && songList.isEmpty()) { + albumId = id + } + songList.add(item) + } + } + + private fun handleMediaFolder(path: String, rootNode: FileNode): FileNode { + val newPath = if (path.endsWith('/')) path.substring(1, path.length - 1) + else path.substring(1) + val splitPath = newPath.split('/') + var node: FileNode = rootNode + for (fld in splitPath.subList(0, splitPath.size - 1)) { + var newNode = node.folderList[fld] + if (newNode == null) { + newNode = FileNode(fld) + node.folderList[newNode.folderName] = newNode + } + node = newNode + } + return node + } + + private fun handleShallowTrack( + mediaItem: Track, + albumId: Long?, + path: String, + shallowFolder: FileNode, + folderArray: MutableList, + ) { + val newPath = if (path.endsWith('/')) path.substring(0, path.length - 1) else path + val splitPath = newPath.split('/') + if (splitPath.size < 2) throw IllegalArgumentException("splitPath.size < 2: $newPath") + val lastFolderName = splitPath[splitPath.size - 2] + var folder = shallowFolder.folderList[lastFolderName] + if (folder == null) { + folder = FileNode(lastFolderName) + shallowFolder.folderList[folder.folderName] = folder + // hack to cut off / + folderArray.add( + newPath.substring(0, splitPath[splitPath.size - 1].length + 1) + ) + } + folder.addSong(mediaItem, albumId) + } + + private val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0" + + listOf( + "audio/x-wav", + "audio/ogg", + "audio/aac", + "audio/midi" + ).joinToString("") { " or ${MediaStore.Audio.Media.MIME_TYPE} = '$it'" } + + private val projection = arrayListOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ARTIST_ID, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ARTIST, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.YEAR, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.TRACK, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED + ).apply { + if (hasImprovedMediaStore()) { + add(MediaStore.Audio.Media.GENRE) + add(MediaStore.Audio.Media.GENRE_ID) + add(MediaStore.Audio.Media.CD_TRACK_NUMBER) + add(MediaStore.Audio.Media.COMPILATION) + add(MediaStore.Audio.Media.DATE_TAKEN) + add(MediaStore.Audio.Media.DISC_NUMBER) + } + }.toTypedArray() + + private fun playlistContent( + context: Context, + playlists: MutableList>>, + ): Boolean { + var foundPlaylistContent = false + context.contentResolver.query( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + arrayOf( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists._ID, + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.NAME, + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.DATE_MODIFIED + ), + null, null, null + )?.use { + val playlistIdColumn = it.getColumnIndexOrThrow( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists._ID + ) + val playlistNameColumn = it.getColumnIndexOrThrow( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.NAME + ) + val playlistModifiedDateColumn = it.getColumnIndexOrThrow( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.DATE_MODIFIED + ) + while (it.moveToNext()) { + val playlistId = it.getLong(playlistIdColumn) + val playlistName = it.getString(playlistNameColumn)?.ifEmpty { null } + val modifiedDate = it.getLong(playlistModifiedDateColumn) + val content = mutableListOf() + context.contentResolver.query( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId), + arrayOf( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.AUDIO_ID + ), null, null, + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC" + )?.use { cursor -> + val column = cursor.getColumnIndexOrThrow( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.AUDIO_ID + ) + while (cursor.moveToNext()) { + foundPlaylistContent = true + content.add(cursor.getLong(column)) + } + } + + val playlist = + MPlaylist(playlistId, playlistName, mutableSetOf(), null, modifiedDate) + playlists.add(Pair(playlist, content)) + } + + } + return foundPlaylistContent + } + + private fun Cursor.parseSongQuery( + limitValue: Int, + folderFilter: Set, + blacklistKeywords: List, + context: Context, + songs: MutableList, + foundPlaylistContent: Boolean, + idMap: MutableMap?, + settings: Settings, + block: (Track) -> Unit, + ) = use { cursor -> + // Get columns from mediaStore. + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val albumArtistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ARTIST) + val trackNumberColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + val yearColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.YEAR) + val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val genreColumn = if (hasImprovedMediaStore()) + cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.GENRE) else null + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val addDateColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + + // prepare exclusions for this cursor scan (pipe-separated) + val artistExclusions = settings.getString(ARTIST_EXCLUSIONS) + ?.split('|') + ?.mapNotNull { it.trim().takeIf { s -> s.isNotBlank() } } + ?.toSet() + ?: emptySet() + + while (cursor.moveToNext()) { + val path = cursor.getStringOrNull(pathColumn) ?: continue + val duration = cursor.getLongOrNull(durationColumn) + val fldPath = File(path).parentFile?.absolutePath + val skip = (duration != null && duration < limitValue * 1000) + || folderFilter.contains(fldPath) + || blacklistKeywords.any { fldPath?.contains(it, true) == true } + // We need to add blacklisted songs to idMap as they can be referenced by playlist + if (skip && !foundPlaylistContent) continue + val id = cursor.getLongOrNull(idColumn)!! + // read metadata strings and normalise any mojibake from Windows-1252 encoded + // smart punctuation. Android sometimes returns garbage like "’" when the + // original tag was U+2019 but decoded incorrectly; fix the common cases here. + // the title must be non-null for Track, so default to empty if missing. + val title = cursor.getStringOrNull(titleColumn) + .fixWin1252() + ?: "" + val artist = cursor.getStringOrNull(artistColumn) + .fixWin1252() + .let { v -> if (v == "") null else v } + val albumName = cursor.getStringOrNull(albumColumn).fixWin1252() + val albumArtist = cursor.getStringOrNull(albumArtistColumn).fixWin1252() + val trackNumber = cursor.getIntOrNull(trackNumberColumn) + val year = cursor.getIntOrNull(yearColumn).let { v -> if (v == 0) null else v } + val albumId = cursor.getLongOrNull(albumIdColumn) + val genre = genreColumn?.let { col -> cursor.getStringOrNull(col) } + val addDate = cursor.getLongOrNull(addDateColumn) + val imgUri = ContentUris.appendId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), id + ).appendPath("albumart").build().takeIf { uri -> + context.contentResolver.runCatching { openInputStream(uri)!!.close() }.isSuccess + } + + val artists = artist.toArtists(artistExclusions) + val album = albumName?.let { + Album(albumId.toString(), it, null, null, albumArtist.toArtists(artistExclusions)) + } + val song = Track( + id = id.toString(), + title = title, + artists = artists, + album = album, + cover = imgUri?.toString()?.toResourceUriImageHolder(), + duration = duration, + releaseDate = year?.toYearDate(), + streamables = listOf(Streamable.server(path, 0, path)), + extras = mapOf( + "genre" to (genre ?: context.getString(R.string.unknown)), + "addDate" to addDate.toString(), + "trackNumber" to trackNumber.toString(), + EXTENSION_ID to OfflineExtension.metadata.id + ) + ) + + // Build our metadata maps/lists. + idMap?.put(id, song) + // Now that the song can be found by playlists, do NOT register other metadata. + if (skip) continue + songs.add(song) + block(song) + } + } + + private val coverUri = "content://media/external/audio/albumart".toUri() + + private fun songAlbumMap( + song: Track, + year: Int?, + albumMap: MutableMap, + artistMap: MutableMap, + haveImgPerm: Boolean, + ) { + val album = song.album ?: return + val id = album.id.toLong() + albumMap.getOrPut(id) { + val cover = if (haveImgPerm) null else ContentUris.withAppendedId(coverUri, id) + val artists = album.artists.map { + MArtist(it.id.toLongOrNull(), it.name, mutableSetOf(), mutableListOf()) + } + + AlbumImpl(id, album.title, artists, year, cover, mutableSetOf()).apply { + artists.forEach { + val mArtist = artistMap.getOrPut(it.id) { + MArtist(it.id, it.title, mutableSetOf(), mutableListOf()) + } + mArtist.albumList.add(this) + } + } + }.songList.add(song) + } + + private val allowedCoverExtensions = listOf("jpg", "png", "jpeg", "bmp", "tiff", "tif", "webp") + + private fun albumMapToAlbumList( + albumMap: MutableMap, + coverCache: HashMap>?, + ) = albumMap.values.onEach { + it.artists.forEach { mArtist -> + mArtist?.albumList?.add(it) + } + // coverCache == null if !haveImgPerm + coverCache?.get(it.id)?.let { p -> + // if this is false, folder contains >1 albums + if (p.second.albumId == it.id) { + var bestScore = 0 + var bestFile: File? = null + try { + val files = p.first.listFiles() ?: return@let + for (file in files) { + if (file.extension !in allowedCoverExtensions) + continue + var score = 1 + when (file.extension) { + "jpg" -> score += 3 + "png" -> score += 2 + "jpeg" -> score += 1 + } + if (file.nameWithoutExtension == "albumart") score += 24 + else if (file.nameWithoutExtension == "cover") score += 20 + else if (file.nameWithoutExtension.startsWith("albumart")) score += 16 + else if (file.nameWithoutExtension.startsWith("cover")) score += 12 + else if (file.nameWithoutExtension.contains("albumart")) score += 8 + else if (file.nameWithoutExtension.contains("cover")) score += 4 + if (bestScore < score) { + bestScore = score + bestFile = file + } + } + } catch (e: Exception) { + Log.e(TAG, Log.getStackTraceString(e)) + } + // allow .jpg or .png files with any name, but only permit more exotic + // formats if name contains either cover or albumart + if (bestScore >= 3) { + bestFile?.let { f -> it.cover = f.toUri() } + } + } + } + }.toMutableList() + + /** + * [getAllSongs] gets all of your songs from your local disk. + * + * @param context + * @return + */ + suspend fun getAllSongs( + context: Context, settings: Settings + ) = withContext(Dispatchers.IO) { + val limitValueSeconds = settings.getInt("limit_value") ?: 10 + val haveImgPerm = if (hasScopedStorage()) context.hasImagePermission() else false + val folderFilter = settings.getStringSet("blacklist_folders") ?: setOf() + val blacklistKeywords = settings.getString("blacklist_keywords")?.split(',') + ?.mapNotNull { it.trim().takeIf { s -> s.isNotBlank() } }.orEmpty() + // parse the user-defined exclusion list; stored as pipe-separated values in settings. + val artistExclusions = settings.getString(ARTIST_EXCLUSIONS) + ?.split('|') + ?.mapNotNull { it.trim().takeIf { s -> s.isNotBlank() } } + ?.toSet() + ?: emptySet() + // Initialize list and maps. + val coverCache = if (haveImgPerm) hashMapOf>() else null + val folders = hashSetOf() + val folderArray = mutableListOf() + val root = FileNode("storage") + val shallowRoot = FileNode("shallow") + val songs = mutableListOf() + val albumMap = mutableMapOf() + val artistMap = mutableMapOf() + val albumArtistMap = + hashMapOf, MutableSet>>() + + val genreMap = hashMapOf() + val dateMap = hashMapOf() + val playlists = mutableListOf>>() + val foundPlaylistContent = + runCatching { playlistContent(context, playlists) }.getOrNull() ?: false + val idMap = hashMapOf() + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + val cursor = context.contentResolver.query( + uri, projection, selection, null, + MediaStore.Audio.Media.TITLE + " COLLATE UNICODE ASC", + ) + cursor?.parseSongQuery( + limitValueSeconds, + folderFilter, + blacklistKeywords, + context, + songs, + foundPlaylistContent, + idMap, + settings, // pass settings so helper can access exclusions + ) { song -> + song.artists.map { + val id = it.id.toLongOrNull() + val mArtist = artistMap.getOrPut(id) { + MArtist(id, it.name, mutableSetOf(), mutableListOf()) + } + mArtist.songList.add(song) + mArtist + } + + val year = song.releaseDate?.year + songAlbumMap(song, year, albumMap, artistMap, haveImgPerm) + + val albumId = song.album?.id?.toLong() + val path = song.streamables.first().id + val parent = File(path).parentFile + parent?.absolutePath?.let { folders.add(it) } + + val fn = handleMediaFolder(path, root) + fn.addSong(song, song.album?.id?.toLong()) + if (albumId != null && parent != null) { + coverCache?.putIfAbsent(albumId, Pair(parent, fn)) + } + handleShallowTrack(song, albumId, path, shallowRoot, folderArray) + + val genre = song.extras["genre"] ?: "Unknown" + genreMap.getOrPut(genre) { + Genre(genre.id(), genre, mutableSetOf()) + }.songList.add(song) + + dateMap.getOrPut(year) { + Date(year?.toLong() ?: 0, year?.toString(), mutableSetOf()) + }.songList.add(song) + } + + // Parse all the lists. + val albumList = albumMapToAlbumList(albumMap, coverCache) + albumArtistMap.entries.forEach { (artist, pair) -> + val albums = pair.first + val song = pair.second + MArtist(artist?.id, artist?.title, song, albums.toMutableList()) + } + val genreList = genreMap.values.toMutableList() + val dateList = dateMap.values.toMutableList() + + val playlistsFinal = playlists.map { + it.first.also { playlist -> + playlist.songList.addAll(it.second.map { value -> + idMap[value] ?: dummyTrack(value, "Unknown [$value]") + }) + } + }.toMutableList() + + val likedPlaylist = getLikedPlaylist(context, playlistsFinal) + likedPlaylist?.let { playlistsFinal.add(0, it) } + + folders.addAll(folderFilter) + LibraryStoreClass( + songs, albumList, artistMap, genreList, dateList, playlistsFinal, + likedPlaylist, root, shallowRoot, folders + ) + } + + private fun getLikedPlaylist( + context: Context, + playlistsFinal: MutableList, + ) = run { + val liked = playlistsFinal.find { it.title == "Liked" } + val id = liked?.id ?: context.createPlaylist("Liked") + liked?.let { playlistsFinal.remove(it) } + MPlaylist( + id ?: return@run null, + context.getString(R.string.playlist_liked), + liked?.songList ?: mutableSetOf(), + context.getString(R.string.playlist_liked_desc), + liked?.modifiedDate ?: (System.currentTimeMillis() / 1000L) + ) + } + + private fun dummyTrack(id: Long, title: String): Track { + return Track(id.toString(), title) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun Context.hasImagePermission() = + checkSelfPermission(android.Manifest.permission.READ_MEDIA_IMAGES) == + PackageManager.PERMISSION_GRANTED + + private fun hasImprovedMediaStore(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + private fun hasScopedStorage(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + fun Context.createPlaylist(title: String): Long? { + val values = ContentValues() + values.put( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.NAME, + title + ) + return contentResolver.insert( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + values + )?.lastPathSegment?.toLong() + } + + fun Context.editPlaylist(id: Long, title: String) { + val values = ContentValues() + values.put( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.NAME, + title + ) + contentResolver.update( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + values, + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists._ID + "=?", + arrayOf(id.toString()) + ) + } + + fun Context.deletePlaylist(id: Long) { + contentResolver.delete( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + @Suppress("DEPRECATION") MediaStore.Audio.Playlists._ID + "=?", + arrayOf(id.toString()) + ) + } + + fun Context.addSongToPlaylist(playlistId: Long, songId: Long, index: Int) { + val values = ContentValues() + values.put( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.PLAY_ORDER, + index + 1 + ) + values.put( + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.AUDIO_ID, + songId + ) + contentResolver.insert( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId), + values + ) + } + + fun Context.removeSongFromPlaylist(playlistId: Long, index: Int) { + contentResolver.delete( + @Suppress("DEPRECATION") + MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId), + @Suppress("DEPRECATION") MediaStore.Audio.Playlists.Members.PLAY_ORDER + "=?", + arrayOf((index + 1).toString()) + ) + } + + fun Context.moveSongInPlaylist(playlistId: Long, song: Long, from: Int, to: Int) { + removeSongFromPlaylist(playlistId, from) + addSongToPlaylist(playlistId, song, to) + } + + fun List.searchBy(query: String, block: (E) -> List) = map { item -> + val qLower = query.lowercase().split(" ").ifEmpty { return@map 5 to item } + val titles = block(item).flatMap { + it?.split(" ") ?: listOf() + }.mapNotNull { + it.takeIf { it.isNotBlank() }?.lowercase() + }.ifEmpty { return@map 5 to item } + val selected = titles.map { t -> + val distance = qLower.sumOf { + val distance = wagnerFischer(t, it) + val bonus = if (t.contains(it)) -t.length else 0 + distance + bonus + } / qLower.size + distance to t + }.minBy { it.first } + selected.first to item + }.filter { it.first <= 3 }.sortedBy { it.first } + + // taken from https://gist.github.com/jmarchesini/e330088e03daa394cf03ddedb8956fbe + private fun wagnerFischer(s: String, t: String): Int { + val m = s.length + val n = t.length + + if (s == t) return 0 + if (s.isEmpty()) return n + if (t.isEmpty()) return m + + val d = Array(m + 1) { IntArray(n + 1) } + + for (i in 1..m) { + d[i][0] = i + } + + for (j in 1..n) { + d[0][j] = j + } + + for (j in 1..n) { + for (i in 1..m) { + val cost = if (s[i - 1] == t[j - 1]) 0 else 1 + val delCost = d[i - 1][j] + 1 + val addCost = d[i][j - 1] + 1 + val subCost = d[i - 1][j - 1] + cost + + d[i][j] = minOf(delCost, addCost, subCost) + } + } + return d[m][n] + } + + private fun String?.splitArtists(exclusions: Set = emptySet()) = + if (this != null && exclusions.contains(this.trim())) + listOf(this.trim()) + else this?.split(",", "&", " and ") + ?.mapNotNull { it.trim().takeIf { s -> s.isNotBlank() } } + ?: listOf(null) + + /** + * Workaround for tags that contain Windows-1252 smart punctuation which was + * mis‑decoded by the media provider. Converts common mojibake sequences back + * to their intended characters. + */ + private fun String?.fixWin1252(): String? = this?.let { s -> + s.replace("’", "’") + .replace("–", "–") + .replace("—", "—") + .replace("“", "“") + .replace("â€�", "”") + .replace("‘", "‘") + .replace("…", "…") + // any additional mappings can be added here + } + + private fun String?.toArtists(exclusions: Set = emptySet()) = + takeIf { this != "null" } + .splitArtists(exclusions) + .map { + Artist(it?.id().toString(), it ?: "Unknown") + } + + fun String.id() = this.lowercase().hashCode().toLong() +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/OfflineExtension.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/OfflineExtension.kt new file mode 100644 index 0000000..4c24d5d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/offline/OfflineExtension.kt @@ -0,0 +1,485 @@ +package dev.brahmkshatriya.echo.extensions.builtin.offline + +import android.content.Context +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.BuildConfig +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.clients.LibraryFeedClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditorListenerClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.SearchFeedClient +import dev.brahmkshatriya.echo.common.clients.SettingsChangeListenerClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.helpers.ClientException +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.loadAll +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeedData +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceImageHolder +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Streamable.Media.Companion.toMedia +import dev.brahmkshatriya.echo.common.models.Streamable.Source.Companion.toSource +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.SettingMultipleChoice +import dev.brahmkshatriya.echo.common.settings.SettingSlider +import dev.brahmkshatriya.echo.common.settings.SettingSwitch +import dev.brahmkshatriya.echo.common.settings.SettingTextInput +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getSettings +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.addSongToPlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.createPlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.deletePlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.editPlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.moveSongInPlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.removeSongFromPlaylist +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.searchBy +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +@OptIn(UnstableApi::class) +class OfflineExtension( + private val context: Context, +) : ExtensionClient, HomeFeedClient, TrackClient, AlbumClient, ArtistClient, PlaylistClient, + RadioClient, LibraryFeedClient, LikeClient, PlaylistEditorListenerClient, + SearchFeedClient, SettingsChangeListenerClient { + + companion object { + val metadata = Metadata( + className = "OfflineExtension", + path = "", + importType = ImportType.BuiltIn, + type = ExtensionType.MUSIC, + id = "echo-offline", + name = "Offline", + description = "An extension for all your downloaded files.", + version = "v${BuildConfig.VERSION_CODE}", + author = "Echo", + icon = R.drawable.ic_offline.toResourceImageHolder(), + ) + } + + override suspend fun onSettingsChanged(settings: Settings, key: String?) { + refreshLibrary() + } + + override suspend fun getSettingItems(): List { + val folders = getLibrary().folders + return listOf( + SettingSwitch( + context.getString(R.string.refresh_library_on_reload), + "refresh_library", + context.getString(R.string.refresh_library_on_reload_summary), + false + ), + SettingSlider( + context.getString(R.string.duration_filter), + "limit_value", + context.getString(R.string.duration_filter_summary), + 10, + 0, + 120, + 10 + ), + SettingMultipleChoice( + context.getString(R.string.blacklist_folders), + "blacklist_folders", + context.getString(R.string.blacklist_folders_summary), + folders.toList(), + folders.toList() + ), + SettingTextInput( + context.getString(R.string.blacklist_folder_keywords), + "blacklist_keywords", + context.getString(R.string.blacklist_folder_keywords_summary) + ), + // user can supply a pipe‑separated list of artist names that should not be + // split even if they contain commas or ampersands (or pipes). Example: + // "Paola & Chiara | Earth, Wind & Fire". + SettingTextInput( + context.getString(R.string.artist_exclusions), + "artist_exclusions", + context.getString(R.string.artist_exclusions_summary) + ) + ) + } + + private val settings = getSettings(context, metadata) + override fun setSettings(settings: Settings) {} + private val refreshLibrary + get() = settings.getBoolean("refresh_library") ?: true + + private val mutex = Mutex() + private var _library: MediaStoreUtils.LibraryStoreClass? = null + private suspend fun getLibrary() = mutex.withLock { + if (_library == null) _library = MediaStoreUtils.getAllSongs(context, settings) + _library!! + } + + private suspend fun refreshLibrary() { + _library = MediaStoreUtils.getAllSongs(context, settings) + } + + private suspend fun find(artist: Artist) = + getLibrary().artistMap[artist.id.toLongOrNull()] + + private suspend fun find(album: Album) = + getLibrary().albumList.find { it.id == album.id.toLong() } + + private suspend fun find(playlist: Playlist) = + getLibrary().playlistList.find { it.id == playlist.id.toLong() } + + private fun List.toShelves(buttons: Feed.Buttons? = null): Feed = + map { it.toShelf() }.toFeed(buttons) + + override suspend fun loadHomeFeed() = Feed(listOf()) { + if (refreshLibrary) refreshLibrary() + val library = getLibrary() + val recentlyAdded = library.songList.sortedByDescending { + it.extras["addDate"]?.toLongOrNull() + }.map { it } + val albums = library.albumList.map { + it.toAlbum() + }.shuffled() + val artists = library.artistMap.values.map { + it.toArtist() + }.shuffled() + + val recent = if (recentlyAdded.isNotEmpty()) Shelf.Lists.Tracks( + "recents", + context.getString(R.string.recently_added), + recentlyAdded.take(9), + more = recentlyAdded.toShelves().takeIf { recentlyAdded.size > 9 } + ) else null + + val albumShelf = if (albums.isNotEmpty()) Shelf.Lists.Items( + "albums", + context.getString(R.string.albums), + albums.take(10), + more = albums.toShelves().takeIf { albums.size > 10 } + ) else null + + val artistsShelf = if (artists.isNotEmpty()) Shelf.Lists.Items( + "artists", + context.getString(R.string.artists), + artists.take(10), + more = artists.toShelves().takeIf { artists.size > 10 } + ) else null + + val data = PagedData.Single { + listOfNotNull(recent, albumShelf, artistsShelf) + library.songList.map { it.toShelf() } + } + data.toFeedData( + Feed.Buttons( + showSearch = false, showSort = true, showPlayAndShuffle = true + ) + ) + } + + override suspend fun loadTrack(track: Track, isDownload: Boolean): Track = track + + override suspend fun loadStreamableMedia(streamable: Streamable, isDownload: Boolean) = + Uri.fromFile(File(streamable.id)).toString().toSource(isLive = false).toMedia() + + override suspend fun loadFeed(track: Track): Feed? = null + + override suspend fun loadAlbum(album: Album) = + find(album)!!.toAlbum() + + override suspend fun loadTracks(album: Album): Feed = PagedData.Single { + find(album)!!.songList.sortedBy { it.extras["trackNumber"]?.toLongOrNull() }.map { it } + }.toFeed() + + override suspend fun loadFeed(album: Album) = + getArtistsWithCategories(album.artists) { it.album?.id != album.id } + + private suspend fun getArtistsWithCategories( + artists: List, filter: (Track) -> Boolean, + ) = artists.map { small -> + val artist = find(small) + val category = artist?.songList?.filter { + filter(it) + }?.map { it }?.ifEmpty { null }?.let { tracks -> + Shelf.Lists.Items( + small.id, + context.getString(R.string.more_by_x, small.name), + tracks, + more = tracks.toShelves() + ) + } + listOfNotNull(artist.toArtist().toShelf(), category) + }.flatten().toFeed() + + override suspend fun loadArtist(artist: Artist) = + find(artist)!!.toArtist() + + override suspend fun loadFeed(artist: Artist): Feed { + return find(artist)!!.run { + val tracks = songList.ifEmpty { null }?.toList() + val albums = albumList.map { it.toAlbum() }.ifEmpty { null } + listOfNotNull( + tracks?.let { + val id = "${artist.id}_tracks" + listOf( + Shelf.Lists.Items( + id, + context.getString(R.string.songs) + " (${it.size})", + listOf(), + more = tracks.toShelves().takeIf { tracks.size >= 10 } + ) + ) + }, + tracks?.take(10)?.map { it.toShelf() }, + albums?.let { + val id = "${artist.id}_albums" + listOf( + Shelf.Lists.Items( + id, + context.getString(R.string.albums) + " (${it.size})", + it, + more = albums.toShelves().takeIf { albums.size >= 4 } + ) + ) + } + ).flatten().toFeed(Feed.Buttons(showPlayAndShuffle = true, customTrackList = tracks)) + } + } + + override suspend fun loadPlaylist(playlist: Playlist) = + if (playlist.id == "cached") playlist else find(playlist)!!.toPlaylist() + + override suspend fun loadTracks(playlist: Playlist): Feed = PagedData.Single { + find(playlist)!!.songList.map { it } + }.toFeed() + + override suspend fun loadFeed(playlist: Playlist): Feed? = null + + override suspend fun loadRadio(radio: Radio) = radio + + override suspend fun loadTracks(radio: Radio): Feed = PagedData.Single { + val mediaItem = radio.extras["mediaItem"]!!.toData().getOrThrow() + val library = getLibrary() + when (mediaItem) { + is Album -> { + val tracks = loadTracks(mediaItem).loadAll().asSequence() + .map { it.artists }.flatten().map { artist -> + library.artistMap[artist.id.toLongOrNull()]?.songList?.map { it }!! + }.flatten().filter { it.album?.id != mediaItem.id }.take(25) + + val randomTracks = library.songList.shuffled().take(25).map { it } + (tracks + randomTracks).distinctBy { it.id }.toMutableList() + } + + is Playlist -> { + val tracks = loadTracks(mediaItem).loadAll() + val randomTracks = getLibrary().songList.shuffled().take(25).map { it } + (tracks + randomTracks).distinctBy { it.id }.toMutableList() + } + + is Artist -> { + val tracks = find(mediaItem)?.songList?.map { it } ?: emptyList() + val randomTracks = getLibrary().songList.shuffled().take(25).map { it } + (tracks + randomTracks).distinctBy { it.id }.toMutableList() + } + + is Track -> { + val albumTracks = mediaItem.album?.let { loadTracks(loadAlbum(it)).loadAll() } + val artistTracks = mediaItem.artists.map { artist -> + find(artist)?.songList ?: emptyList() + }.flatten().map { it } + val randomTracks = getLibrary().songList.shuffled().take(25).map { it } + val allTracks = + listOfNotNull(albumTracks, artistTracks, randomTracks).flatten() + .distinctBy { it.id } + .toMutableList() + allTracks.removeIf { it.id == mediaItem.id } + allTracks + } + + else -> throw IllegalAccessException() + }.shuffled() + }.toFeed() + + override suspend fun radio(item: EchoMediaItem, context: EchoMediaItem?): Radio { + val id = "radio_${item.hashCode()}" + val title = item.title + return Radio( + id = id, + title = this.context.getString(R.string.x_radio, title), + extras = mapOf("mediaItem" to item.toJson()) + ) + } + + private fun List.sorted() = + sortedBy { it.title.lowercase() }.map { it.toShelf() } + + private fun List.toPair(buttons: Feed.Buttons? = null) = + PagedData.Single { this }.toFeedData(buttons) + + private fun Pair, Boolean>.toFeed() = + first.toPair(if (second) Feed.Buttons(false, showSort = true) else null) + + override suspend fun loadSearchFeed(query: String) = Feed( + (if (query.isBlank()) listOf("Songs", "Albums", "Artists", "Genres") + else listOf("All", "Songs", "Albums", "Artists")).map { Tab(it, it) } + ) { tab -> + if (query.isBlank()) { + if (refreshLibrary) refreshLibrary() + when (tab?.id) { + "Albums" -> getLibrary().albumList.map { it.toAlbum() }.sorted().toPair( + Feed.Buttons(showSearch = false, showSort = true) + ) + + "Artists" -> getLibrary().artistMap.values.map { it.toArtist() }.sorted().toPair( + Feed.Buttons(showSearch = false, showSort = true) + ) + + "Genres" -> getLibrary().genreList.map { it.toShelf() }.toPair() + else -> getLibrary().songList.sortedByDescending { + it.extras["addDate"]?.toLongOrNull() + }.map { it.toShelf() }.toPair( + Feed.Buttons( + showSearch = false, showSort = true, showPlayAndShuffle = true + ) + ) + } + } else { + val tracks = getLibrary().songList.map { it }.searchBy(query) { + listOf(it.title, it.album?.title) + it.artists.map { artist -> artist.name } + } + val albums = getLibrary().albumList.map { it.toAlbum() }.searchBy(query) { + listOf(it.title) + it.artists.map { artist -> artist.name } + } + val artists = getLibrary().artistMap.values.map { it.toArtist() }.searchBy(query) { + listOf(it.name) + } + + when (tab?.id) { + "Songs" -> tracks.map { it.second.toShelf() } to true + "Albums" -> albums.map { it.second.toShelf() } to true + "Artists" -> artists.map { it.second.toShelf() } to true + else -> { + val items = listOf( + "Songs" to tracks, "Albums" to albums, "Artist" to artists + ).sortedBy { it.second.firstOrNull()?.first ?: 20 } + .map { it.first to it.second.map { pair -> pair.second } } + .filter { it.second.isNotEmpty() } + + val exactMatch = items.firstNotNullOfOrNull { + it.second.find { item -> item.title.contains(query, true) } + }?.toShelf() + + val containers = items.map { (title, items) -> + val id = "${query}_$title" + Shelf.Lists.Items(id, title, items, more = items.toShelves()) + } + + listOf(listOfNotNull(exactMatch), containers).flatten() to false + } + }.toFeed() + } + } + + override suspend fun loadLibraryFeed() = Feed( + listOf("Playlists", "Folders").map { Tab(it, it) } + ) { tab -> + if (refreshLibrary) refreshLibrary() + val pagedData: PagedData = when (tab?.id) { + "Folders" -> getLibrary().folderStructure.folderList.entries.firstOrNull()?.value + ?.toShelf(context, null)?.feed?.getPagedData?.invoke(null)?.pagedData + ?: PagedData.Single { listOf() } + + else -> PagedData.Single { + getLibrary().playlistList.map { it.toPlaylist().toShelf() } + } + } + pagedData.toFeedData(Feed.Buttons()) + } + + override suspend fun listEditablePlaylists(track: Track?) = getLibrary().playlistList.map { + val has = it.songList.any { song -> song.id == track?.id } + it.toPlaylist() to has + } + + override suspend fun likeItem(item: EchoMediaItem, shouldLike: Boolean) { + val library = getLibrary() + val playlist = library.likedPlaylist?.id + ?: throw ClientException.NotSupported("Couldn't create Liked Playlist") + if (shouldLike) context.addSongToPlaylist(playlist, item.id.toLong(), 0) + else { + val index = library.likedPlaylist.songList.indexOfFirst { it.id == item.id } + context.removeSongFromPlaylist(playlist, index) + } + refreshLibrary() + } + + override suspend fun isItemLiked(item: EchoMediaItem) = + getLibrary().likedPlaylist?.songList?.find { it.id == item.id } != null + + override suspend fun createPlaylist(title: String, description: String?): Playlist { + val id = context.createPlaylist(title) + refreshLibrary() + return getLibrary().playlistList.find { it.id == id }!!.toPlaylist() + } + + override suspend fun deletePlaylist(playlist: Playlist) { + context.deletePlaylist(playlist.id.toLong()) + refreshLibrary() + } + + override suspend fun editPlaylistMetadata( + playlist: Playlist, title: String, description: String?, + ) { + context.editPlaylist(playlist.id.toLong(), title) + } + + override suspend fun addTracksToPlaylist( + playlist: Playlist, tracks: List, index: Int, new: List, + ) { + new.forEach { + context.addSongToPlaylist(playlist.id.toLong(), it.id.toLong(), index) + } + } + + override suspend fun removeTracksFromPlaylist( + playlist: Playlist, tracks: List, indexes: List, + ) { + indexes.forEach { index -> + context.removeSongFromPlaylist(playlist.id.toLong(), index) + } + } + + override suspend fun moveTrackInPlaylist( + playlist: Playlist, tracks: List, fromIndex: Int, toIndex: Int, + ) { + val song = tracks[fromIndex].id.toLong() + context.moveSongInPlaylist(playlist.id.toLong(), song, fromIndex, toIndex) + } + + override suspend fun onEnterPlaylistEditor(playlist: Playlist, tracks: List) {} + override suspend fun onExitPlaylistEditor(playlist: Playlist, tracks: List) { + refreshLibrary() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/DownloadExtension.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/DownloadExtension.kt new file mode 100644 index 0000000..be267a8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/DownloadExtension.kt @@ -0,0 +1,139 @@ +package dev.brahmkshatriya.echo.extensions.builtin.test + +import android.content.Context +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.DownloadClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.DownloadContext +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed.Companion.loadAll +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Progress +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.providers.MusicExtensionsProvider +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import java.io.File + +class DownloadExtension( + val context: Context +) : DownloadClient, MusicExtensionsProvider { + + companion object { + val metadata = Metadata( + "DownloadExtension", + "", + ImportType.BuiltIn, + ExtensionType.MISC, + "test_download", + "Test Download Extension", + "1.0.0", + "Test extension for download testing", + "Test", + ) + } + + override val concurrentDownloads = 2 + + override suspend fun selectServer(context: DownloadContext): Streamable { + return context.track.servers.first() + } + + override suspend fun selectSources( + context: DownloadContext, server: Streamable.Media.Server + ): List { + return server.sources + } + + override suspend fun download( + progressFlow: MutableStateFlow, + context: DownloadContext, + source: Streamable.Source + ) = test(progressFlow, "Downloading", 10000) + + override suspend fun merge( + progressFlow: MutableStateFlow, + context: DownloadContext, + files: List + ) = test(progressFlow, "Merging", 5000) + + override suspend fun tag( + progressFlow: MutableStateFlow, + context: DownloadContext, + file: File + ) = test(progressFlow, "Tagging", 2000) + + override suspend fun getDownloadTracks( + extensionId: String, + item: EchoMediaItem, + context: EchoMediaItem? + ): List { + return when (item) { + is Track -> listOf(DownloadContext(extensionId, item)) + is EchoMediaItem.Lists -> { + val ext = exts.first { it.id == extensionId } + val tracks = when (item) { + is Album -> ext.getAs> { + val album = loadAlbum(item) + val tracks = loadTracks(album)!!.loadAll() + tracks + } + + is Playlist -> ext.getAs> { + val album = loadPlaylist(item) + val tracks = loadTracks(album).loadAll() + tracks + } + + is Radio -> ext.getAs> { + val radio = loadRadio(item) + loadTracks(radio).loadAll() + } + + }.getOrThrow() + tracks.mapIndexed { index, track -> + DownloadContext(extensionId, track, index, item) + } + } + + else -> listOf() + } + } + + private suspend fun test( + progressFlow: MutableSharedFlow, + type: String, + crash: Long + ): File { + progressFlow.emit(Progress(crash, 0)) + var it = 0L + while (it < crash) { + delay(1) + progressFlow.emit(Progress(crash, it)) + it++ + } + if (type == "Tagging") throw Exception("Test exception in $type") + return this.context.cacheDir + } + + override suspend fun getSettingItems() = listOf() + override fun setSettings(settings: Settings) {} + override val requiredMusicExtensions = listOf() + + private lateinit var exts: List + override fun setMusicExtensions(extensions: List) { + exts = extensions + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TestExtension.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TestExtension.kt new file mode 100644 index 0000000..17daa32 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TestExtension.kt @@ -0,0 +1,366 @@ +package dev.brahmkshatriya.echo.extensions.builtin.test + +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.FollowClient +import dev.brahmkshatriya.echo.common.clients.HideClient +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.clients.LibraryFeedClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.common.clients.LoginClient.InputField +import dev.brahmkshatriya.echo.common.clients.LyricsSearchClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.SaveClient +import dev.brahmkshatriya.echo.common.clients.ShareClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.clients.TrackerMarkClient +import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.helpers.WebViewClient +import dev.brahmkshatriya.echo.common.helpers.WebViewRequest +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeedData +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.models.NetworkRequest +import dev.brahmkshatriya.echo.common.models.NetworkRequest.Companion.toGetRequest +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Streamable.Media.Companion.toBackgroundMedia +import dev.brahmkshatriya.echo.common.models.Streamable.Media.Companion.toServerMedia +import dev.brahmkshatriya.echo.common.models.Streamable.Media.Companion.toSubtitleMedia +import dev.brahmkshatriya.echo.common.models.Streamable.Source.Companion.toSource +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.models.TrackDetails +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.common.providers.WebViewClientProvider +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.Settings +import kotlinx.coroutines.delay +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.random.Random + +@Suppress("unused") +class TestExtension : ExtensionClient, LoginClient.CustomInput, TrackClient, LoginClient.WebView, + HomeFeedClient, FollowClient, RadioClient, WebViewClientProvider, ArtistClient, + LyricsSearchClient, LibraryFeedClient, + SaveClient, LikeClient, HideClient, TrackerMarkClient, ShareClient { + + companion object { + val metadata = Metadata( + "TestExtension", + "", + ImportType.BuiltIn, + ExtensionType.MUSIC, + "test", + "Test Extension", + "1.0.0", + "Test extension for offline testing", + "Test", + icon = "https://yt3.googleusercontent.com/UMGZZMPQkM3kGtyW4jNE1GtpSrydfNJdbG1UyWTp5zeqUYc6-rton70Imm7B11RulRRuK521NQ=s160-c-k-c0x00ffffff-no-rj".toImageHolder() + ) + + const val FUN = + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4" + + const val BUNNY = + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + + const val M3U8 = + "https://fl3.moveonjoy.com/CNBC/index.m3u8" + + const val SUBTITLE = + "https://raw.githubusercontent.com/brenopolanski/html5-video-webvtt-example/master/MIB2-subtitles-pt-BR.vtt" + + private fun createTrack(id: String, title: String, streamables: List) = Track( + id, + title, + cover = "https://picsum.photos/seed/$id/300".toImageHolder(), + isExplicit = Random.nextBoolean(), + streamables = streamables + ).toShelf() + } + + override suspend fun getSettingItems() = listOf() + override fun setSettings(settings: Settings) {} + + override val forms = listOf( + LoginClient.Form( + "bruh", + "Test Form", + InputField.Type.Username, + listOf( + InputField(InputField.Type.Username, "name", "Name", true), + InputField(InputField.Type.Password, "password", "Password", false), + InputField(InputField.Type.Email, "email", "EMail", false), + InputField(InputField.Type.Misc, "text", "Text", false), + InputField(InputField.Type.Number, "number", "Number", false), + InputField(InputField.Type.Url, "url", "Url", false), + ) + ) + ) + + override suspend fun onLogin(key: String, data: Map): List { + return listOf( + User( + key, + "$key User", + "https://picsum.photos/seed/$key/200".toImageHolder(), + ) + ) + } + + override val webViewRequest = object : WebViewRequest.Cookie> { + override suspend fun onStop(url: NetworkRequest, cookie: String): List { + return listOf( + User( + "test_user", + "WebView User", + "https://picsum.photos/seed/test_user/200".toImageHolder(), + ) + ) + } + + override val initialUrl = "https://www.example.com/".toGetRequest() + override val stopUrlRegex = "https://www\\.iana\\.org/.*".toRegex() + } + + override fun setLoginUser(user: User?) { +// println("setLoginUser: $user") + } + + override suspend fun getCurrentUser(): User? = null + + override suspend fun loadStreamableMedia( + streamable: Streamable, isDownload: Boolean, + ): Streamable.Media { + if (streamable.quality == 3) throw Exception("Test exception for quality 3") + return when (streamable.type) { + Streamable.MediaType.Background -> streamable.id.toBackgroundMedia() + Streamable.MediaType.Server -> { + val srcs = Srcs.valueOf(streamable.id) + when (srcs) { + Srcs.Single -> FUN.toServerMedia() + Srcs.Merged -> Streamable.Media.Server( + listOf( + BUNNY.toSource(), + FUN.toSource(), + ), false + ) + + Srcs.M3U8 -> M3U8.toServerMedia(type = Streamable.SourceType.HLS) + } + } + + Streamable.MediaType.Subtitle -> streamable.id.toSubtitleMedia(Streamable.SubtitleType.VTT) + } + } + + override suspend fun loadFeed(track: Track): Feed? = null + + enum class Srcs { + Single, Merged, M3U8; + + fun createTrack() = createTrack( + name, name, listOf(Streamable.subtitle(SUBTITLE)) + (0..5).map { i -> + Streamable.server(this.name, i) + } + ) + } + + private lateinit var webViewClient: WebViewClient + override fun setWebViewClient(webViewClient: WebViewClient) { + this.webViewClient = webViewClient + } + + override suspend fun loadHomeFeed(): Feed = Feed( + listOf("All", "Music", "Podcasts").map { Tab(it, it) } + ) { tab -> + if (tab?.id == "Podcasts") { + throw Exception("Test exception for Podcasts tab") +// return@Feed PagedData.empty().toFeedData() + } + PagedData.Single { + if (tab?.id == "Music") delay(5000) + OkHttpClient().newCall( + Request.Builder().url("https://example.com").build() + ).await().body + listOf( + Shelf.Lists.Categories( + "bruh", + "Bruh ${tab?.title}", + listOf( + "Burhhhhhhh", "brjdksls", "sbajkxclllll", "a", "b", " b" + ).map { Shelf.Category(it, it, loadHomeFeed()) } + ), + Shelf.Lists.Categories( + "bruh", + "Bruh ${tab?.title}", + listOf( + "Burhhhhhhh", "brjdksls", "sbajkxclllll", "a", "b", " b" + ).map { Shelf.Category(it, it, loadHomeFeed()) }, + type = Shelf.Lists.Type.Grid + ), + Artist( + "bruh", + "Bruh", + cover = "https://www.easygifanimator.net/images/samples/video-to-gif-sample.gif" + .toImageHolder() + ).toShelf(), + Track("not", "Not Playable", isPlayable = Track.Playable.Unreleased).toShelf(), + createTrack( + "all", "All", listOf( + Streamable.server(Srcs.Single.name, 0), + Streamable.server(Srcs.Merged.name, 0), + Streamable.server(Srcs.M3U8.name, 0), + Streamable.subtitle(SUBTITLE) + ) + ), + Srcs.Single.createTrack(), + Srcs.Merged.createTrack(), + Srcs.M3U8.createTrack() + ) + }.toFeedData(background = "https://picsum.photos/id/21/400/300".toImageHolder()) + } + + private val radio = Radio("empty", "empty") + override suspend fun loadRadio(radio: Radio) = radio + override suspend fun loadTracks(radio: Radio) = listOf( + Track("", "Bruh") + ).toFeed() + + override suspend fun radio(item: EchoMediaItem, context: EchoMediaItem?) = radio + + + override suspend fun loadFeed(artist: Artist) = PagedData.Single { + listOf( + Srcs.Single.createTrack(), + artist.toShelf(), + ) + }.toFeed() + + override suspend fun loadArtist(artist: Artist) = artist + + private var isSaved = false + override suspend fun saveToLibrary(item: EchoMediaItem, shouldSave: Boolean) { + println(item.extras["loaded"]) + isSaved = shouldSave + println("save $shouldSave") + } + + override suspend fun isItemSaved(item: EchoMediaItem): Boolean { + println(item.extras["loaded"]) + println("isSaved : $isSaved") + return isSaved + } + + + private var isLiked = false + override suspend fun likeItem(item: EchoMediaItem, shouldLike: Boolean) { + println(item.extras["loaded"]) + isLiked = shouldLike + println("like $shouldLike") + } + + override suspend fun isItemLiked(item: EchoMediaItem): Boolean { + println(item.extras["loaded"]) + println("isLiked : $isLiked") + return isLiked + } + + private var isHidden = false + override suspend fun hideItem(item: EchoMediaItem, shouldHide: Boolean) { + println("hide") + this.isHidden = shouldHide + } + + override suspend fun isItemHidden(item: EchoMediaItem): Boolean { + println("isHidden : $isHidden") + return isHidden + } + + override suspend fun loadTrack(track: Track, isDownload: Boolean): Track = track.copy( + extras = mapOf("loaded" to "Loaded bro") + ) + + override suspend fun onTrackChanged(details: TrackDetails?) { + println("onTrackChanged : $details") + } + + override suspend fun onPlayingStateChanged(details: TrackDetails?, isPlaying: Boolean) { + println("onPlayingStateChanged $isPlaying : $details") + } + + override suspend fun getMarkAsPlayedDuration(details: TrackDetails) = 10000L + override suspend fun onMarkAsPlayed(details: TrackDetails) { + println("onMarkAsPlayed : $details") + } + + private var isFollowing = false + override suspend fun isFollowing(item: EchoMediaItem) = isFollowing + override suspend fun getFollowersCount(item: EchoMediaItem): Long = 1000L + override suspend fun followItem(item: EchoMediaItem, shouldFollow: Boolean) { + println("followItem: $item, follow: $shouldFollow") + isFollowing = shouldFollow + } + + override suspend fun onShare(item: EchoMediaItem): String { + return "https://example.com/${item.id}" + } + + val lyrics = listOf( + Lyrics("1", "Test Lyrics 1", lyrics = Lyrics.Simple("First line\nSecond line\nThird line")), + Lyrics( + "2", "Test Lyrics 2", lyrics = Lyrics.Timed( + listOf( + Lyrics.Item("First line", 0, 1000), + Lyrics.Item("Second line", 1000, 2000), + Lyrics.Item("Third line", 2000, 3000) + ) + ) + ), + Lyrics( + "3", "Test Lyrics 3", lyrics = Lyrics.WordByWord( + listOf( + listOf(Lyrics.Item("First", 0, 500), Lyrics.Item("line", 500, 1000)), + listOf(Lyrics.Item("Second", 1000, 1500), Lyrics.Item("line", 1500, 2000)), + listOf(Lyrics.Item("Third", 2000, 2500), Lyrics.Item("line", 2500, 3000)) + ) + ) + ), + ) + + override suspend fun searchLyrics(query: String): Feed = Feed( + listOf("Simple", "Timed", "WordByWord", "All").map { Tab(it, it) } + ) { tab -> + delay(3000) + val burh = when (tab?.id) { + "Simple" -> lyrics.filter { it.lyrics is Lyrics.Simple } + "Timed" -> lyrics.filter { it.lyrics is Lyrics.Timed } + "WordByWord" -> lyrics.filter { it.lyrics is Lyrics.WordByWord } + else -> lyrics + } + burh.toFeedData() + } + + override suspend fun searchTrackLyrics(clientId: String, track: Track) = run { + delay(5000) + lyrics.toFeed() + } + + override suspend fun loadLyrics(lyrics: Lyrics) = lyrics + override suspend fun loadLibraryFeed() = (0..10).map { + createTrack("lib_$it", "Library Track $it", listOf(Streamable.server(Srcs.Single.name, 0))) + }.toFeed(Feed.Buttons(showPlayAndShuffle = true)) + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TrackerTestExtension.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TrackerTestExtension.kt new file mode 100644 index 0000000..7ce5fcb --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/test/TrackerTestExtension.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.extensions.builtin.test + +import dev.brahmkshatriya.echo.common.clients.TrackerMarkClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.models.TrackDetails +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.Settings + +class TrackerTestExtension : TrackerMarkClient { + companion object { + val metadata = Metadata( + "TrackerTestExtension", + "", + ImportType.BuiltIn, + ExtensionType.TRACKER, + "test", + "Tracker Test Extension", + "1.0.0", + "Test extension for offline testing", + "Test", + ) + } + + override suspend fun getSettingItems() = listOf() + override fun setSettings(settings: Settings) {} + + override suspend fun onTrackChanged(details: TrackDetails?) { + println("onTrackChanged ${details?.track?.id}") + } + + override suspend fun getMarkAsPlayedDuration(details: TrackDetails): Long? { + return details.totalDuration?.div(3) + } + + override suspend fun onMarkAsPlayed(details: TrackDetails) { + println("onMarkAsPlayed: ${details.track.id}") + } + + override suspend fun onPlayingStateChanged(details: TrackDetails?, isPlaying: Boolean) { + println("onPlayingStateChanged $isPlaying: ${details?.track?.id}") + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedDatabase.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedDatabase.kt new file mode 100644 index 0000000..a642845 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedDatabase.kt @@ -0,0 +1,356 @@ +package dev.brahmkshatriya.echo.extensions.builtin.unified + +import android.content.Context +import androidx.core.net.toUri +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Date +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceUriImageHolder +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedDatabase.PlaylistEntity.Companion.toEntity +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedDatabase.PlaylistTrackEntity.Companion.toTrackEntity +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedDatabase.SavedEntity.Companion.toEntity +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.UNIFIED_ID +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.extensionId +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import java.io.File +import java.util.Calendar + +@Database( + entities = [ + UnifiedDatabase.PlaylistEntity::class, + UnifiedDatabase.PlaylistTrackEntity::class, + UnifiedDatabase.SavedEntity::class, + ], + version = 6, + exportSchema = false +) +abstract class UnifiedDatabase : RoomDatabase() { + abstract fun playlistDao(): PlaylistDao + + private val dao by lazy { playlistDao() } + + suspend fun getCreatedPlaylists(): List { + return dao.getPlaylists().filter { it.actualId.isEmpty() }.map { it.playlist } + } + + suspend fun getSaved(): List { + return dao.getSaved().mapNotNull { it.item.getOrNull() } + } + + suspend fun isSaved(item: EchoMediaItem): Boolean { + val extId = item.extras.extensionId + return dao.isSaved(item.id, extId) + } + + suspend fun save(item: EchoMediaItem) { + dao.insertSaved(item.toEntity()) + } + + suspend fun deleteSaved(item: EchoMediaItem) { + dao.deleteSaved(item.toEntity()) + } + + private fun getDateNow(): Date { + val calendar = Calendar.getInstance() + calendar.time = java.util.Date() + return Date( + year = calendar.get(Calendar.YEAR), + month = calendar.get(Calendar.MONTH), + day = calendar.get(Calendar.DAY_OF_MONTH), + ) + } + + suspend fun getLikedPlaylist(context: Context): Playlist { + val liked = dao.getPlaylist("Liked") + return if (liked == null) { + val playlist = PlaylistEntity( + 0, + getDateNow().toJson(), + "Liked", + context.getString(R.string.unified_liked_playlist_summary), + null, + "[]" + ) + val id = dao.insertPlaylist(playlist) + playlist.copy(id = id).playlist + } else liked.playlist + } + + suspend fun createPlaylist( + title: String, + description: String?, + cover: ImageHolder? = null, + actualId: String = "", + ): Playlist { + val playlist = PlaylistEntity( + 0, + getDateNow().toJson(), + title, + description ?: "", + cover?.toJson(), + "[]", + actualId + ) + val id = dao.insertPlaylist(playlist) + return playlist.copy(id = id).playlist + } + + suspend fun deletePlaylist(playlist: Playlist) { + dao.deleteAllTracks(playlist.id.toLong()) + dao.deletePlaylist(playlist.toEntity()) + } + + suspend fun editPlaylistMetadata(playlist: Playlist, title: String, description: String?) { + val entity = playlist.toEntity().copy(name = title, description = description ?: "") + dao.insertPlaylist(entity) + } + + suspend fun editPlaylistCover(playlist: Playlist, file: File?) { + val image: ImageHolder? = file?.toUri()?.toString()?.toResourceUriImageHolder(true) + val entity = playlist.toEntity().copy( + cover = image?.toJson() + ) + dao.insertPlaylist(entity) + } + + suspend fun loadPlaylist(playlist: Playlist): Playlist { + val entity = dao.getPlaylist(playlist.toEntity().id) + val tracks = dao.getTracks(entity.id).map { it.track } + if (tracks.isEmpty()) return playlist.copy(trackCount = 0, duration = null) + val durations = tracks.mapNotNull { it.duration } + val average = durations.average().toLong() + return entity.playlist.copy( + trackCount = tracks.size.toLong(), + duration = average * tracks.size + ) + } + + suspend fun getTracks(playlist: Playlist): List { + val entity = dao.getPlaylist(playlist.toEntity().id) + val tracks = dao.getTracks(entity.id).associateBy { it.eid } + if (tracks.isEmpty()) return emptyList() + return entity.list.map { tracks[it]!!.track } + } + + suspend fun addTracksToPlaylist( + playlist: Playlist, index: Int, new: List, + ) { + if (new.isEmpty()) return + val entity = dao.getPlaylist(playlist.toEntity().id) + val newTracks = new.map { + val trackEntity = PlaylistTrackEntity( + 0, entity.id, it.id, it.extras.extensionId, it.toJson() + ) + dao.insertPlaylistTrack(trackEntity) + } + val newEntity = entity.copy( + listData = entity.list.toMutableList().apply { + addAll(index, newTracks) + }.toJson() + ) + dao.insertPlaylist(newEntity) + } + + suspend fun removeTracksFromPlaylist( + playlist: Playlist, tracks: List, indexes: List, + ) { + val entities = indexes.map { tracks[it].toTrackEntity() } + entities.forEach { dao.deletePlaylistTrack(it) } + val entity = dao.getPlaylist(playlist.toEntity().id) + val newEntity = entity.copy( + listData = entity.list.toMutableList().apply { + entities.forEach { + val index = indexOf(it.eid) + if (index != -1) removeAt(index) + } + }.toJson() + ) + dao.insertPlaylist(newEntity) + } + + suspend fun moveTrack(playlist: Playlist, fromIndex: Int, toIndex: Int) { + val entity = dao.getPlaylist(playlist.toEntity().id) + val newEntity = entity.copy( + listData = entity.list.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + }.toJson() + ) + dao.insertPlaylist(newEntity) + } + + suspend fun isLiked(track: Track): Boolean { + val liked = dao.getPlaylist("Liked") ?: return false + return dao.getTracks(liked.id) + .any { it.trackId == track.id && it.extId == track.extras.extensionId } + } + + suspend fun getOrCreate(app: Context, context: EchoMediaItem): Playlist { + return dao.getPlaylistByActualId(context.id)?.playlist ?: createPlaylist( + context.title, + app.getString(R.string.downloaded_x, ""), + context.cover, + context.id + ) + } + + suspend fun getPlaylist(mediaItem: EchoMediaItem): Playlist? { + return dao.getPlaylistByActualId(mediaItem.id)?.playlist + } + + @Dao + interface PlaylistDao { + @Query("SELECT * FROM PlaylistEntity") + suspend fun getPlaylists(): List + + @Query("SELECT * FROM PlaylistEntity WHERE id = :id") + suspend fun getPlaylist(id: Long): PlaylistEntity + + @Query("SELECT * FROM PlaylistEntity WHERE name = :name") + suspend fun getPlaylist(name: String): PlaylistEntity? + + @Query("SELECT * FROM PlaylistEntity WHERE actualId = :actualId") + suspend fun getPlaylistByActualId(actualId: String): PlaylistEntity? + + @Insert(onConflict = REPLACE) + suspend fun insertPlaylist(playlist: PlaylistEntity): Long + + @Delete + suspend fun deletePlaylist(playlist: PlaylistEntity) + + @Query("SELECT * FROM PlaylistTrackEntity WHERE playlistId = :playlistId") + suspend fun getTracks(playlistId: Long): List + + @Insert(onConflict = REPLACE) + suspend fun insertPlaylistTrack(playlistTrack: PlaylistTrackEntity): Long + + @Delete + suspend fun deletePlaylistTrack(playlistTrack: PlaylistTrackEntity) + + @Query("DELETE FROM PlaylistTrackEntity WHERE playlistId = :playlistId") + suspend fun deleteAllTracks(playlistId: Long) + + @Query("SELECT * FROM SavedEntity") + suspend fun getSaved(): List + + @Query("SELECT EXISTS(SELECT 1 FROM SavedEntity WHERE id = :id AND extId = :extId)") + suspend fun isSaved(id: String, extId: String): Boolean + + @Insert(onConflict = REPLACE) + suspend fun insertSaved(saved: SavedEntity): Long + + @Delete + suspend fun deleteSaved(saved: SavedEntity) + + @Query("SELECT * FROM PlaylistTrackEntity WHERE eid = :eid") + suspend fun getTrack(eid: Long?): PlaylistTrackEntity? + + @Query("SELECT * FROM PlaylistTrackEntity WHERE \"after\" = :eid") + suspend fun getAfterTrack(eid: Long): PlaylistTrackEntity? + } + + @Entity + data class PlaylistEntity( + @PrimaryKey(true) + val id: Long, + val modified: String, + val name: String, + val description: String, + val cover: String?, + val listData: String, + val actualId: String = "", + ) { + val list by lazy { + listData.toData>().getOrThrow() + } + + val playlist by lazy { + Playlist( + id.toString(), + name, + true, + cover = cover?.toData()?.getOrThrow(), + creationDate = modified.toData().getOrNull(), + description = description.takeIf { it.isNotBlank() }, + extras = mapOf( + EXTENSION_ID to UNIFIED_ID, + "listData" to listData, + "actualId" to actualId + ) + ) + } + + companion object { + fun Playlist.toEntity(): PlaylistEntity { + return PlaylistEntity( + id.toLong(), + creationDate.toJson(), + title, + description ?: "", + cover?.toJson(), + extras["listData"] ?: "[]", + extras["actualId"] ?: "" + ) + } + } + } + + @Entity + data class PlaylistTrackEntity( + @PrimaryKey(autoGenerate = true) + val eid: Long, + val playlistId: Long, + val trackId: String, + val extId: String, + val data: String, + ) { + val track by lazy { + data.toData().getOrThrow().run { + this.copy( + type = type, + extras = extras + mapOf( + "pId" to playlistId.toString(), + "eId" to eid.toString(), + ) + ) + } + } + + companion object { + fun Track.toTrackEntity(): PlaylistTrackEntity { + val pId = extras["pId"]!!.toLong() + val eId = extras["eId"]!!.toLong() + return PlaylistTrackEntity(eId, pId, id, extras.extensionId, this.toJson()) + } + } + } + + @Entity(primaryKeys = ["id", "extId"]) + data class SavedEntity( + val id: String, + val extId: String, + val data: String, + ) { + val item by lazy { data.toData() } + + companion object { + fun EchoMediaItem.toEntity(): SavedEntity { + val extId = extras.extensionId + return SavedEntity(id, extId, this.toJson()) + } + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedExtension.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedExtension.kt new file mode 100644 index 0000000..52b110c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/builtin/unified/UnifiedExtension.kt @@ -0,0 +1,689 @@ +package dev.brahmkshatriya.echo.extensions.builtin.unified + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache +import androidx.room.Room +import dev.brahmkshatriya.echo.BuildConfig +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.FollowClient +import dev.brahmkshatriya.echo.common.clients.HideClient +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.clients.LibraryFeedClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.LyricsClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditCoverClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.SaveClient +import dev.brahmkshatriya.echo.common.clients.SearchFeedClient +import dev.brahmkshatriya.echo.common.clients.ShareClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.clients.TrackerClient +import dev.brahmkshatriya.echo.common.clients.TrackerMarkClient +import dev.brahmkshatriya.echo.common.helpers.ClientException +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.loadAll +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeedData +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.common.models.TrackDetails +import dev.brahmkshatriya.echo.common.providers.MusicExtensionsProvider +import dev.brahmkshatriya.echo.common.settings.SettingSwitch +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import dev.brahmkshatriya.echo.playback.MediaItemUtils.toKey +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import java.io.File + +@OptIn(UnstableApi::class) +class UnifiedExtension( + private val app: App, + private val cache: SimpleCache, +) : ExtensionClient, MusicExtensionsProvider, + HomeFeedClient, SearchFeedClient, LibraryFeedClient, + PlaylistClient, AlbumClient, ArtistClient, TrackClient, + FollowClient, RadioClient, LikeClient, SaveClient, HideClient, ShareClient, + PlaylistEditClient, PlaylistEditCoverClient, LyricsClient, TrackerMarkClient { + + companion object { + const val UNIFIED_ID = "unified" + const val EXTENSION_ID = "extension_id" + val metadata = Metadata( + "UnifiedExtension", + "", + ImportType.BuiltIn, + ExtensionType.MUSIC, + UNIFIED_ID, + "Unified Extension", + version = "v${BuildConfig.VERSION_CODE}", + "All your extensions in one place!", + "Echo", + isEnabled = true + ) + + fun Context.getFeed(items: List): Feed { + if (items.isEmpty()) return listOf().toFeed() + val types = items.groupBy { + when (it) { + is Track -> getString(R.string.track) + is Album -> getString(R.string.album) + is Artist -> getString(R.string.artists) + is Playlist -> getString(R.string.playlist) + is Radio -> getString(R.string.radio) + } + } + val tabs = if (types.keys.size == 1) listOf() + else getString(R.string.all).let { listOf(Tab(it, it)) } + + types.keys.map { Tab(it, it, true) } + + return Feed(tabs) { tab -> + val items = types[tab?.id]?.toList() ?: items + items.map { it.toShelf() }.toFeedData() + } + } + + suspend inline fun Extension<*>.client(block: C.() -> T): T = runCatching { + val client = instance.value().getOrThrow() as? C + ?: throw ClientException.NotSupported(C::class.run { simpleName ?: java.name }) + client.block() + }.getOrElse { throw it.toAppException(this) } + + suspend inline fun Extension<*>.clientOrNull(block: C.() -> T): T? = + runCatching { + val client = instance.value().getOrThrow() as? C + client?.block() + }.getOrElse { throw it.toAppException(this) } + + private fun List>.get(id: String?) = + find { it.id == id } ?: throw Exception("Extension $id not found") + + private fun List>.getOrNull(id: String?) = find { it.id == id } + + val Map.extensionId + get() = this[EXTENSION_ID] ?: throw Exception("Extension id not found") + + fun Track.withExtensionId( + id: String, client: Any?, cached: Boolean = false, + ) = copy( + extras = extras + mapOf(EXTENSION_ID to id, "cached" to cached.toString()), + album = album?.withExtensionId(id, client), + artists = artists.map { it.withExtensionId(id, client) }, + streamables = streamables.map { + it.copy(extras = it.extras + mapOf(EXTENSION_ID to id)) + }, + isSaveable = true, + isLikeable = true, + isHideable = client is HideClient && isHideable, + isRadioSupported = client is RadioClient && isRadioSupported, + isFollowable = client is FollowClient && isFollowable, + isShareable = client is ShareClient && isShareable + ) + + private fun Album.withExtensionId(id: String, client: Any?) = copy( + artists = artists.map { it.withExtensionId(id, client) }, + extras = extras + mapOf(EXTENSION_ID to id), + isSaveable = true, + isLikeable = false, + isHideable = client is HideClient && isHideable, + isRadioSupported = client is RadioClient && isRadioSupported, + isFollowable = client is FollowClient && isFollowable, + isShareable = client is ShareClient && isShareable + ) + + private fun Artist.withExtensionId(id: String, client: Any?) = copy( + extras = extras + mapOf(EXTENSION_ID to id), + isSaveable = true, + isLikeable = false, + isHideable = client is HideClient && isHideable, + isRadioSupported = client is RadioClient && isRadioSupported, + isFollowable = client is FollowClient && isFollowable, + isShareable = client is ShareClient && isShareable + ) + + private fun Playlist.withExtensionId(id: String, client: Any?) = copy( + isEditable = false, + authors = authors.map { it.withExtensionId(id, client) }, + extras = extras + mapOf(EXTENSION_ID to id), + isSaveable = true, + isLikeable = false, + isHideable = client is HideClient && isHideable, + isRadioSupported = client is RadioClient && isRadioSupported, + isFollowable = client is FollowClient && isFollowable, + isShareable = client is ShareClient && isShareable + ) + + private fun Radio.withExtensionId(id: String, client: Any?) = copy( + extras = extras + mapOf(EXTENSION_ID to id), + isSaveable = true, + isLikeable = false, + isHideable = client is HideClient && isHideable, + isFollowable = client is FollowClient && isFollowable, + isShareable = client is ShareClient && isShareable + ) + + private fun EchoMediaItem.withExtensionId( + id: String, client: Any?, + ) = when (this) { + is Artist -> this.withExtensionId(id, client) + is Album -> this.withExtensionId(id, client) + is Playlist -> this.withExtensionId(id, client) + is Radio -> this.withExtensionId(id, client) + is Track -> this.withExtensionId(id, client) + } + + private fun Lyrics.withExtensionId(id: String) = copy( + extras = extras + mapOf(EXTENSION_ID to id) + ) + + fun Shelf.Item.withExtensionId(id: String, client: Any?) = copy( + media = media.withExtensionId(id, client) + ) + + fun Feed.Buttons.withExtensionId(extension: Extension<*>) = copy( + customTrackList = customTrackList?.map { + it.withExtensionId(extension.id, extension.instance.value) + } + ) + + private fun Feed.injectExtensionId(extension: Extension<*>) = copy( + tabs = tabs.map { it.injectId(extension.id) }, + getPagedData = { tab -> + val (data, buttons, bg) = runCatching { getPagedData(tab) }.getOrElse { + throw it.toAppException(extension) + } + data.injectExtensionId(extension) + .toFeedData(buttons?.withExtensionId(extension), bg) + } + ) + + private fun Feed.injectExtension(extension: Extension<*>) = copy( + tabs = tabs.map { it.injectId(extension.id) }, + getPagedData = { tab -> + val id = extension.id + val (data, buttons, bg) = runCatching { getPagedData(tab) }.getOrElse { + throw it.toAppException(extension) + } + data.map { result -> + val list = result.getOrElse { throw it.toAppException(extension) } + list.map { it.withExtensionId(id, extension.instance.value) } + }.toFeedData(buttons?.withExtensionId(extension), bg) + } + ) + + fun Feed.injectLyricsExtId(extension: Extension<*>) = copy( + tabs = tabs.map { it.injectId(extension.id) }, + ) { tab -> + val (data, buttons, bg) = runCatching { getPagedData(tab) }.getOrElse { + throw it.toAppException(extension) + } + data.map { result -> + val id = extension.id + val list = result.getOrElse { throw it.toAppException(extension) } + list.map { it.withExtensionId(id) } + }.toFeedData(buttons?.withExtensionId(extension), bg) + } + + private fun Shelf.Category.withExtensionId(extension: Extension<*>): Shelf.Category = + copy(feed = feed?.injectExtensionId(extension)) + + private fun PagedData.injectExtensionId(extension: Extension<*>): PagedData = + map { result -> + val id = extension.id + val client = extension.instance.value + val list = result.getOrElse { throw it.toAppException(extension) } + list.map { + when (it) { + is Shelf.Category -> it.withExtensionId(extension) + is Shelf.Item -> it.withExtensionId(id, client) + is Shelf.Lists.Categories -> it.copy( + list = it.list.map { category -> category.withExtensionId(extension) }, + more = it.more?.injectExtensionId(extension) + ) + + is Shelf.Lists.Items -> it.copy( + list = it.list.map { item -> item.withExtensionId(id, client) }, + more = it.more?.injectExtensionId(extension) + ) + + is Shelf.Lists.Tracks -> it.copy( + list = it.list.map { track -> + track.withExtensionId(id, client) + }, + more = it.more?.injectExtensionId(extension) + ) + } + } + } + + fun Tab.injectId(id: String) = copy(extras = extras + mapOf(EXTENSION_ID to id)) + } + + private val context = app.context + + override suspend fun getSettingItems() = listOf( + SettingSwitch( + context.getString(R.string.show_tabs), + "show_tabs", + context.getString(R.string.show_tab_summary), + false + ), + ) + + private lateinit var settings: Settings + override fun setSettings(settings: Settings) { + this.settings = settings + } + + private val showTabs get() = settings.getBoolean("show_tabs") ?: false + + override val requiredMusicExtensions = listOf() + private var extFlow = MutableStateFlow?>(null) + suspend fun extensions() = extFlow.first { it != null }!! + override fun setMusicExtensions(extensions: List) { + extFlow.value = extensions.filter { it.id != UNIFIED_ID && it.metadata.isEnabled } + } + + private suspend inline fun Extension<*>.getFeedData( + crossinline loadFeed: suspend T.() -> Feed, + ): Feed.Data { + val feed = client> { loadFeed() }.injectExtensionId(this) + val data = feed.run { getPagedData(tabs.firstOrNull()) } + val otherTabs = feed.tabs.drop(1).map { tab -> + Shelf.Category( + tab.id, + tab.title, + Feed(listOf()) { feed.run { getPagedData(tab) } } + ) + } + return if (feed.tabs.size > 1 && showTabs) { + Feed.Data( + PagedData.Concat( + PagedData.Single { + listOf( + Shelf.Lists.Categories( + "tabs", + context.getString(R.string.tabs), + otherTabs + ) + ) + }, + data.pagedData + ), + data.buttons?.withExtensionId(this), + data.background + ) + } else data + } + + private suspend inline fun feed( + crossinline loadFeed: suspend T.() -> Feed, + ): Feed { + val list = extensions() + return if (list.size == 1) { + val ext = list.first() + ext.client> { loadFeed() }.injectExtensionId(ext) + } else Feed( + list.map { Tab(it.id, it.name).injectId(it.id) } + ) { tab -> + val extensions = extensions() + val id = tab?.extras?.extensionId ?: extensions.firstOrNull()?.id + extensions.get(id).getFeedData(loadFeed) + } + } + + override suspend fun loadHomeFeed() = feed { loadHomeFeed() } + + override suspend fun loadSearchFeed(query: String): Feed { + return feed { loadSearchFeed(query) } + } + + val db = Room.databaseBuilder( + context, UnifiedDatabase::class.java, "unified-db" + ).fallbackToDestructiveMigration(true).build() + + private suspend fun getCached() = cache.keys.mapNotNull { + val key = context.getFromCache(it, "player") + key?.toKey()?.getOrNull() + }.reversed().groupBy { it.trackId }.mapNotNull { + var (id, _, extId) = it.value.first() + val state = Cached.getMedia(app, extId, id).getOrNull() + ?: return@mapNotNull null + if (extId == UNIFIED_ID) extId = state.item.extras[EXTENSION_ID] ?: return@mapNotNull null + val client = extensions().getOrNull(extId)?.instance?.value + state.item.withExtensionId(extId, client, true) + } + + private var cachedTracks = listOf() + private fun cachePlaylist() = if (cachedTracks.isNotEmpty()) Playlist( + id = "cached", + title = context.getString(R.string.cached_songs), + isEditable = false, + cover = cachedTracks.first().cover, + description = context.getString(R.string.cache_playlist_warning), + trackCount = cachedTracks.size.toLong(), + extras = mapOf(EXTENSION_ID to UNIFIED_ID) + ) else null + + val downloadFeed = MutableStateFlow(listOf()) + override suspend fun loadLibraryFeed() = Feed( + listOf(Tab("Unified", context.getString(R.string.all))) + extensions().map { + Tab(it.id, it.name) + } + ) { tab -> + val extension = extensions().getOrNull(tab?.id) + extension?.getFeedData { loadLibraryFeed() } ?: run { + PagedData.Single { + cachedTracks = getCached() + listOfNotNull( + Shelf.Category( + "saved", + context.getString(R.string.saved), + context.getFeed(db.getSaved()) + ), + Shelf.Category( + "downloads", + context.getString(R.string.downloads), + context.getFeed(downloadFeed.value) + ), + cachePlaylist()?.toShelf() + ) + db.getCreatedPlaylists().map { it.toShelf() } + }.toFeedData() + } + } + + override suspend fun loadRadio(radio: Radio): Radio { + val id = radio.extras.extensionId + val extension = extensions().get(id) + return extension.client { + loadRadio(radio).withExtensionId(id, this) + } + } + + override suspend fun loadTracks(radio: Radio): Feed { + val id = radio.extras.extensionId + val extension = extensions().get(id) + return extension.client> { + this.loadTracks(radio).injectExtension(extension) + } + } + + override suspend fun radio(item: EchoMediaItem, context: EchoMediaItem?): Radio { + val id = item.extras.extensionId + return extensions().get(id).client { + radio(item, context).withExtensionId(id, this) + } + } + + override suspend fun loadTrack(track: Track, isDownload: Boolean): Track { + val cached = track.extras["cached"]?.toBoolean() ?: false + if (cached) return track + val id = track.extras.extensionId + return extensions().get(id).client { + loadTrack(track, isDownload).withExtensionId(id, this) + } + } + + override suspend fun loadStreamableMedia( + streamable: Streamable, isDownload: Boolean, + ): Streamable.Media { + val id = streamable.extras.extensionId + return extensions().get(id).client { + loadStreamableMedia(streamable, isDownload) + } + } + + override suspend fun loadFeed(track: Track): Feed? { + val id = track.extras.extensionId + val extension = extensions().get(id) + return extension.client?> { + loadFeed(track)?.injectExtensionId(extension) + } + } + + override suspend fun loadAlbum(album: Album): Album { + val id = album.extras.extensionId + return extensions().get(id).client { + loadAlbum(album).withExtensionId(id, this) + } + } + + override suspend fun loadTracks(album: Album): Feed? { + val id = album.extras.extensionId + val extension = extensions().get(id) + return extension.client?> { + loadTracks(album)?.injectExtension(extension) + } + } + + override suspend fun loadFeed(album: Album): Feed? { + val id = album.extras.extensionId + val extension = extensions().get(id) + return extension.client?> { + loadFeed(album)?.injectExtensionId(extension) + } + } + + override suspend fun loadArtist(artist: Artist): Artist { + val id = artist.extras.extensionId + return extensions().get(id).client { + loadArtist(artist).withExtensionId(id, this) + } + } + + override suspend fun loadFeed(artist: Artist): Feed { + val id = artist.extras.extensionId + val extension = extensions().get(id) + return extension.client> { + loadFeed(artist).injectExtensionId(extension) + } + } + + override suspend fun loadPlaylist(playlist: Playlist): Playlist { + val extId = playlist.extras.extensionId + return if (extId == UNIFIED_ID) { + if (playlist.id == "cached") cachePlaylist() ?: playlist else db.loadPlaylist(playlist) + } else extensions().get(extId).client { + loadPlaylist(playlist).withExtensionId(extId, this) + } + } + + override suspend fun loadTracks(playlist: Playlist): Feed { + val id = playlist.extras.extensionId + return if (id == UNIFIED_ID) PagedData.Single { + if (playlist.id == "cached") cachedTracks + else db.getTracks(playlist) + }.toFeed() + else { + val extension = extensions().get(id) + extension.client> { + loadTracks(playlist).injectExtension(extension) + } + } + } + + override suspend fun loadFeed(playlist: Playlist): Feed? { + val id = playlist.extras.extensionId + val extension = if (id != UNIFIED_ID) extensions().get(id) else return null + return extension.client?> { + loadFeed(playlist)?.injectExtensionId(extension) + } + } + + override suspend fun saveToLibrary(item: EchoMediaItem, shouldSave: Boolean) { + if (shouldSave) db.save(item) else db.deleteSaved(item) + } + + override suspend fun isItemSaved(item: EchoMediaItem): Boolean { + return db.isSaved(item) + } + + override suspend fun listEditablePlaylists(track: Track?) = db.getCreatedPlaylists().map { + val has = db.getTracks(it).any { t -> t.id == track?.id } + it to has + } + + override suspend fun createPlaylist(title: String, description: String?): Playlist { + return db.createPlaylist(title, description) + } + + val coverDir = context.filesDir.resolve("unified-playlist-covers") + override suspend fun deletePlaylist(playlist: Playlist) { + db.deletePlaylist(playlist) + File(coverDir, playlist.id).let { + if (it.exists()) it.delete() + } + } + + override suspend fun editPlaylistMetadata( + playlist: Playlist, title: String, description: String? + ) { + db.editPlaylistMetadata(playlist, title, description) + } + + override suspend fun editPlaylistCover( + playlist: Playlist, cover: File? + ) { +// coverDir.listFiles { +// it.nameWithoutExtension == playlist.id +// }?.firstOrNull()?.delete() +// val savedFile = cover?.let { +// val newFile = File(coverDir, playlist.id + "."+ it.extension) +// it.copyTo(newFile, true) +// } + db.editPlaylistCover(playlist, cover) + } + + override suspend fun addTracksToPlaylist( + playlist: Playlist, tracks: List, index: Int, new: List, + ) { + db.addTracksToPlaylist(playlist, index, new) + } + + override suspend fun removeTracksFromPlaylist( + playlist: Playlist, tracks: List, indexes: List, + ) { + db.removeTracksFromPlaylist(playlist, tracks, indexes) + } + + override suspend fun moveTrackInPlaylist( + playlist: Playlist, tracks: List, fromIndex: Int, toIndex: Int, + ) { + db.moveTrack(playlist, fromIndex, toIndex) + } + + override suspend fun searchTrackLyrics(clientId: String, track: Track): Feed { + val extId = track.extras.extensionId + val extension = extensions().get(extId) + return extension.clientOrNull> { + searchTrackLyrics(clientId, track).injectLyricsExtId(extension) + } ?: listOf().toFeed() + } + + override suspend fun loadLyrics(lyrics: Lyrics): Lyrics { + val extId = lyrics.extras.extensionId + return extensions().get(extId).client { + loadLyrics(lyrics).withExtensionId(extId) + } + } + + private var current: Track? = null + override suspend fun onTrackChanged(details: TrackDetails?) { + current = details?.track + val id = details?.track?.extras?.extensionId ?: return + val extension = extensions().get(id) + extension.clientOrNull { onTrackChanged(details) } + } + + override suspend fun onMarkAsPlayed(details: TrackDetails) { + val id = details.track.extras.extensionId + val extension = extensions().get(id) + extension.clientOrNull { onMarkAsPlayed(details) } + } + + override suspend fun onPlayingStateChanged(details: TrackDetails?, isPlaying: Boolean) { + val id = details?.track?.extras?.extensionId ?: return + val extension = extensions().get(id) + extension.clientOrNull { onPlayingStateChanged(details, isPlaying) } + } + + override suspend fun getMarkAsPlayedDuration(details: TrackDetails): Long? { + val extension = extensions().get(details.track.extras.extensionId) + return extension.clientOrNull { getMarkAsPlayedDuration(details) } + } + + override suspend fun onShare(item: EchoMediaItem): String { + val id = item.extras.extensionId + val extension = extensions().get(id) + return extension.client { onShare(item) } + } + + override suspend fun isFollowing(item: EchoMediaItem): Boolean { + val id = item.extras.extensionId + val extension = extensions().get(id) + return extension.client { isFollowing(item) } + } + + override suspend fun getFollowersCount(item: EchoMediaItem): Long? { + val id = item.extras.extensionId + val extension = extensions().get(id) + return extension.client { getFollowersCount(item) } + } + + override suspend fun followItem(item: EchoMediaItem, shouldFollow: Boolean) { + val id = item.extras.extensionId + val extension = extensions().get(id) + extension.client { followItem(item, shouldFollow) } + } + + override suspend fun likeItem(item: EchoMediaItem, shouldLike: Boolean) { + if (item !is Track) throw ClientException.NotSupported("LikeItem only supports Track") + val likedPlaylist = db.getLikedPlaylist(context) + val tracks = loadTracks(likedPlaylist).loadAll() + if (shouldLike) addTracksToPlaylist(likedPlaylist, tracks, 0, listOf(item)) + else removeTracksFromPlaylist( + likedPlaylist, tracks, listOf(tracks.indexOfFirst { it.id == item.id }) + ) + } + + override suspend fun isItemLiked(item: EchoMediaItem): Boolean { + if (item !is Track) throw ClientException.NotSupported("IsItemLiked only supports Track") + return db.isLiked(item) + } + + override suspend fun hideItem(item: EchoMediaItem, shouldHide: Boolean) { + val id = item.extras.extensionId + val extension = extensions().get(id) + extension.client { hideItem(item, shouldHide) } + } + + override suspend fun isItemHidden(item: EchoMediaItem): Boolean { + val id = item.extras.extensionId + val extension = extensions().get(id) + return extension.client { isItemHidden(item) } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/cache/Cached.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/cache/Cached.kt new file mode 100644 index 0000000..9576014 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/cache/Cached.kt @@ -0,0 +1,296 @@ +package dev.brahmkshatriya.echo.extensions.cache + +import com.mayakapps.kache.FileKache +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.FollowClient +import dev.brahmkshatriya.echo.common.clients.HideClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.LyricsClient +import dev.brahmkshatriya.echo.common.clients.LyricsSearchClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.SaveClient +import dev.brahmkshatriya.echo.common.clients.ShareClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.helpers.Page +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeedData +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.io.File + +object Cached { + class NotFound(id: String) : Exception("Cache not found for $id") + + suspend inline fun FileKache.getData(id: String) = runCatching { + val file = get(id) ?: throw NotFound(id) + File(file).readText().toData().getOrThrow() + } + + suspend inline fun FileKache.putData(id: String, data: T) = runCatching { + put(id) { + runCatching { + File(it).writeText(data.toJson()) + }.isSuccess + } + } + + suspend inline fun getMedia( + app: App, extensionId: String, itemId: String, + ) = runCatching { + val fileCache = app.fileCache.await() + val id = "media-$extensionId-$itemId-state" + fileCache.getData>(id).getOrThrow() + } + + suspend inline fun loadMedia( + app: App, extension: Extension<*>, state: MediaState, + ) = coroutineScope { + runCatching { + val result = runCatching { + val new = loadItem(extension, state.item).getOrThrow() + val isSaved = async { + if (new.isSaveable) extension.getIf { + isItemSaved(new) + } else null + } + val isFollowed = async { + if (new.isFollowable) extension.getIf { + isFollowing(new) + } else null + } + val followers = async { + if (new.isFollowable) extension.getIf { + getFollowersCount(new) + } else null + } + val isLiked = async { + if (new.isLikeable) extension.getIf { + isItemLiked(new) + } else null + } + val isHidden = async { + if (new.isHideable) extension.getIf { + isItemHidden(new) + } else null + } + val newState = MediaState.Loaded( + item = new, + extensionId = extension.id, + isSaved = isSaved.await()?.getOrThrow(), + isFollowed = isFollowed.await()?.getOrThrow(), + followers = followers.await()?.getOrThrow(), + isLiked = isLiked.await()?.getOrThrow(), + isHidden = isHidden.await()?.getOrThrow(), + showRadio = new.isRadioSupported && extension.isClient(), + showShare = new.isShareable && extension.isClient(), + ) + val fileCache = app.fileCache.await() + val id = "media-${extension.id}-${newState.item.id}-state" + fileCache.putData(id, newState) + newState + } + result.getOrElse { + getMedia(app, extension.id, state.item.id).getOrNull() + ?: throw it + } + } + } + + suspend inline fun loadItem( + extension: Extension<*>, item: T, + ) = runCatching { + when (item) { + is Artist -> extension.getAs { loadArtist(item) } + is Album -> extension.getAs { loadAlbum(item) } + is Playlist -> extension.getAs { loadPlaylist(item) } + is Track -> extension.getAs { loadTrack(item, false) } + is Radio -> extension.getAs { loadRadio(item) } + }.getOrThrow() as T + } + + suspend fun loadStreamableMedia( + app: App, extension: Extension<*>, track: Track, streamable: Streamable, + ) = runCatching { + val fileCache = app.fileCache.await() + val extId = track.extras[EXTENSION_ID] ?: extension.id + val id = "media-${extId}-${track.id}-${streamable.id}" + val media = extension.getAs { + loadStreamableMedia(streamable, false) + }.getOrElse { throwable -> + fileCache.getData(id).getOrNull() ?: throw throwable + } + fileCache.putData(id, media) + media + } + + suspend fun getTracks( + app: App, extensionId: String, item: EchoMediaItem, + ) = runCatching { + if (item !is EchoMediaItem.Lists) return@runCatching null + val itemId = item.id + getFeed(app, extensionId, "$itemId-tracks") { it } + } + + suspend fun loadTracks(app: App, extension: Extension<*>, item: EchoMediaItem) = runCatching { + val feed = when (item) { + is Album -> extension.getAs?> { loadTracks(item) } + is Playlist -> extension.getAs> { loadTracks(item) } + is Radio -> extension.getAs> { loadTracks(item) } + is Artist -> null + is Track -> null + }?.getOrThrow() ?: return@runCatching null + savingFeed(app, extension, "${item.id}-tracks", feed) + } + + suspend fun getFeed( + app: App, extensionId: String, item: EchoMediaItem, + ) = run { + if (item !is EchoMediaItem.Lists) return@run null + val itemId = item.id + getFeedShelf(app, extensionId, itemId) + } + + suspend fun loadFeed(app: App, extension: Extension<*>, item: EchoMediaItem) = runCatching { + val feed = when (item) { + is Artist -> extension.getAs> { loadFeed(item) } + is Album -> extension.getAs?> { loadFeed(item) } + is Playlist -> extension.getAs?> { loadFeed(item) } + is Track -> extension.getAs?> { loadFeed(item) } + is Radio -> null + }?.getOrThrow() ?: return@runCatching null + savingFeed(app, extension, item.id, feed) + } + + suspend fun loadLyrics(app: App, extension: Extension<*>, lyrics: Lyrics) = runCatching { + val fileCache = app.fileCache.await() + val id = "lyrics-${extension.id}-${lyrics.id}" + val loaded = extension.getAs { + loadLyrics(lyrics) + }.getOrElse { throwable -> + fileCache.getData(id).getOrNull() ?: throw throwable + } + fileCache.putData(id, loaded) + loaded + } + + suspend fun getLyricsFeed( + app: App, extensionId: String, clientId: String, track: Track, query: String, + ) = runCatching { + val id = if (query.isEmpty()) "lyrics-$clientId-${track.id}" else "lyrics-search-$query" + getFeed(app, extensionId, id) { it } + } + + suspend fun loadLyricsFeed( + app: App, extension: Extension<*>, clientId: String, track: Track, query: String, + ) = runCatching { + val feed = if (query.isEmpty()) extension.getAs> { + searchTrackLyrics(clientId, track) + }.getOrThrow() else extension.getAs> { + searchLyrics(query) + }.getOrThrow() + val id = if (query.isEmpty()) "lyrics-$clientId-${track.id}" else "lyrics-search-$query" + savingFeed(app, extension, id, feed) + } + + suspend fun getFeedShelf( + app: App, extensionId: String, feedId: String, + ): Result> = runCatching { + getFeed(app, extensionId, feedId) { shelf -> + when (shelf) { + is Shelf.Item -> shelf + is Shelf.Category -> shelf.copy( + feed = getFeedShelf(app, extensionId, shelf.id).getOrNull() + ) + + is Shelf.Lists.Categories -> shelf.copy( + list = shelf.list.map { + it.copy(feed = getFeedShelf(app, extensionId, it.id).getOrNull()) + }, + more = getFeedShelf(app, extensionId, shelf.id).getOrNull() + ) + + is Shelf.Lists.Items -> shelf.copy( + more = getFeedShelf(app, extensionId, shelf.id).getOrNull() + ) + + is Shelf.Lists.Tracks -> shelf.copy( + more = getFeedShelf(app, extensionId, shelf.id).getOrNull() + ) + } + } + } + + + // FEED STUFF + + suspend inline fun getFeed( + app: App, extensionId: String, feedId: String, crossinline transform: suspend (T) -> T, + ): Feed { + val fileCache = app.fileCache.await() + val tabId = "feed-$extensionId-$feedId" + val tabs = fileCache.getData>(tabId).getOrThrow() + return Feed(tabs) { tab -> + val id = "$tabId-${tab?.id}" + val (buttons, bg) = fileCache.getData>(id) + .getOrThrow() + PagedData.Continuous { token -> + val id = "$id-$token" + val page = fileCache.getData>(id).getOrThrow() + page.copy(page.data.map { transform(it) }) + }.toFeedData(buttons, bg) + } + } + + suspend inline fun savingFeed( + app: App, extension: Extension<*>, feedId: String, feed: Feed, + ): Feed { + val fileCache = app.fileCache.await() + val tabId = "feed-${extension.id}-$feedId" + fileCache.putData(tabId, feed.tabs) + return Feed(feed.tabs) { tab -> + val data = runCatching { + feed.getPagedData(tab) + }.getOrElse { + throw it.toAppException(extension) + } + val (pagedData, buttons, bg) = data + val id = "$tabId-${tab?.id}" + fileCache.putData(id, Pair(buttons, bg)) + PagedData.Continuous { token -> + val page = runCatching { + pagedData.loadPage(token) + }.getOrElse { + throw it.toAppException(extension) + } + val id = "$id-$token" + fileCache.putData(id, page) + page + }.toFeedData(buttons, bg) + } + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDao.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDao.kt new file mode 100644 index 0000000..dfa88f9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDao.kt @@ -0,0 +1,18 @@ +package dev.brahmkshatriya.echo.extensions.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.brahmkshatriya.echo.extensions.db.models.ExtensionEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExtensionDao { + + @Query("SELECT * FROM ExtensionEntity") + fun getExtensionFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setExtension(extensionEntity: ExtensionEntity) +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDatabase.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDatabase.kt new file mode 100644 index 0000000..667e234 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/ExtensionDatabase.kt @@ -0,0 +1,40 @@ +package dev.brahmkshatriya.echo.extensions.db + +import android.app.Application +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.ExtensionEntity +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity + +@Database( + entities = [ + UserEntity::class, + CurrentUser::class, + ExtensionEntity::class, + ], + version = 1, + exportSchema = false +) +abstract class ExtensionDatabase : RoomDatabase() { + private val userDao by lazy { userDao() } + val currentUsersFlow by lazy { userDao.observeCurrentUser() } + private val extensionDao by lazy { extensionDao() } + val extensionEnabledFlow by lazy { extensionDao.getExtensionFlow() } + + abstract fun userDao(): UserDao + abstract fun extensionDao(): ExtensionDao + + suspend fun getUser(current: CurrentUser): User? { + return userDao.getUser(current.type, current.extId, current.userId)?.user?.getOrNull() + } + + companion object { + private const val DATABASE_NAME = "extension-db" + fun create(app: Application) = Room.databaseBuilder( + app, ExtensionDatabase::class.java, DATABASE_NAME + ).fallbackToDestructiveMigration(true).build() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/UserDao.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/UserDao.kt new file mode 100644 index 0000000..2f649fc --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/UserDao.kt @@ -0,0 +1,37 @@ +package dev.brahmkshatriya.echo.extensions.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface UserDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setCurrentUser(currentUser: CurrentUser) + + @Query("SELECT * FROM CurrentUser") + fun observeCurrentUser(): Flow> + + @Query("SELECT * FROM UserEntity WHERE type = :type AND extId = :extId") + fun observeAllUsers(type: ExtensionType, extId: String): Flow> + + @Query("SELECT * FROM CurrentUser WHERE extId = :extId AND type = :type") + suspend fun getCurrentUser(type: ExtensionType, extId: String): CurrentUser? + + @Delete + suspend fun deleteUser(user: UserEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertUsers(users: List) + + @Query("SELECT * FROM UserEntity WHERE type = :type AND extId = :extId AND id = :userId") + suspend fun getUser(type: ExtensionType, extId: String, userId: String?): UserEntity? + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/CurrentUser.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/CurrentUser.kt new file mode 100644 index 0000000..e9214d3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/CurrentUser.kt @@ -0,0 +1,11 @@ +package dev.brahmkshatriya.echo.extensions.db.models + +import androidx.room.Entity +import dev.brahmkshatriya.echo.common.models.ExtensionType + +@Entity(primaryKeys = ["type", "extId"]) +data class CurrentUser( + val type : ExtensionType, + val extId: String, + val userId: String? +) \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/ExtensionEntity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/ExtensionEntity.kt new file mode 100644 index 0000000..39d02a5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/ExtensionEntity.kt @@ -0,0 +1,11 @@ +package dev.brahmkshatriya.echo.extensions.db.models + +import androidx.room.Entity +import dev.brahmkshatriya.echo.common.models.ExtensionType + +@Entity(primaryKeys = ["id", "type"]) +data class ExtensionEntity( + val id: String, + val type : ExtensionType, + val enabled : Boolean +) \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/UserEntity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/UserEntity.kt new file mode 100644 index 0000000..fd717c2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/db/models/UserEntity.kt @@ -0,0 +1,25 @@ +package dev.brahmkshatriya.echo.extensions.db.models + +import androidx.room.Entity +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson + +@Entity(primaryKeys = ["id", "type", "extId"]) +data class UserEntity( + val type: ExtensionType, + val extId: String, + val id: String, + val data: String +) { + val user by lazy { data.toData() } + + companion object { + fun User.toEntity(type: ExtensionType, clientId: String) = + UserEntity(type, clientId, id, toJson()) + + fun UserEntity.toCurrentUser() = + CurrentUser(type, extId, id) + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/AppException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/AppException.kt new file mode 100644 index 0000000..1929c92 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/AppException.kt @@ -0,0 +1,46 @@ +package dev.brahmkshatriya.echo.extensions.exceptions + +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.helpers.ClientException +import dev.brahmkshatriya.echo.common.models.Metadata + +sealed class AppException : Exception() { + + abstract val extension: Metadata + + open class LoginRequired( + override val extension: Metadata + ) : AppException() + + class Unauthorized( + override val extension: Metadata, + val userId: String + ) : LoginRequired(extension) + + class NotSupported( + override val cause: Throwable, + override val extension: Metadata, + val operation: String + ) : AppException() { + override val message: String + get() = "$operation is not supported in ${extension.name}" + } + + class Other( + override val cause: Throwable, + override val extension: Metadata + ) : AppException() { + override val message: String + get() = "${cause.message} error in ${extension.name}" + } + + companion object { + fun Throwable.toAppException(extension: Extension<*>) = toAppException(extension.metadata) + fun Throwable.toAppException(extension: Metadata): AppException = when (this) { + is ClientException.Unauthorized -> Unauthorized(extension, userId) + is ClientException.LoginRequired -> LoginRequired(extension) + is ClientException.NotSupported -> NotSupported(this, extension, operation) + else -> Other(this, extension) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionLoaderException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionLoaderException.kt new file mode 100644 index 0000000..e110372 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionLoaderException.kt @@ -0,0 +1,7 @@ +package dev.brahmkshatriya.echo.extensions.exceptions + +class ExtensionLoaderException( + val clazz: String, + val source: String, + override val cause: Throwable +) : Exception() \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionNotFoundException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionNotFoundException.kt new file mode 100644 index 0000000..11f0a5b --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/ExtensionNotFoundException.kt @@ -0,0 +1,3 @@ +package dev.brahmkshatriya.echo.extensions.exceptions + +class ExtensionNotFoundException(val id: String?) : Exception("Extension not found: $id") \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/InvalidExtensionListException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/InvalidExtensionListException.kt new file mode 100644 index 0000000..da5b93a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/InvalidExtensionListException.kt @@ -0,0 +1,5 @@ +package dev.brahmkshatriya.echo.extensions.exceptions + +class InvalidExtensionListException( + val link: String, override val cause: Throwable +) : Exception() \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/RequiredExtensionsMissingException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/RequiredExtensionsMissingException.kt new file mode 100644 index 0000000..596f829 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/exceptions/RequiredExtensionsMissingException.kt @@ -0,0 +1,4 @@ +package dev.brahmkshatriya.echo.extensions.exceptions + +class RequiredExtensionsMissingException(val required: List) : + Exception("Missing required extensions: ${required.joinToString(", ")}") \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/AppRepository.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/AppRepository.kt new file mode 100644 index 0000000..b4936dd --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/AppRepository.kt @@ -0,0 +1,76 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.extensions.repo.ExtensionParser.Companion.FEATURE +import dev.brahmkshatriya.echo.extensions.repo.ExtensionParser.Companion.PACKAGE_FLAGS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.util.WeakHashMap +import kotlin.coroutines.suspendCoroutine + +class AppRepository( + private val scope: CoroutineScope, + private val context: Context, + private val parser: ExtensionParser +) : ExtensionRepository { + private suspend fun Context.getStaticPackages() = withContext(Dispatchers.IO) { + runCatching { + packageManager.getInstalledPackages(PACKAGE_FLAGS).mapNotNull { + runCatching { + val isExtension = it.reqFeatures.orEmpty().any { featureInfo -> + featureInfo?.name?.startsWith(FEATURE) ?: false + } + if (isExtension) File(it.applicationInfo!!.sourceDir!!) else null + }.getOrNull() + } + }.getOrNull().orEmpty() + } + + private val map = + WeakHashMap>>>>() + private val mutex = Mutex() + + override suspend fun loadExtensions() = mutex.withLock { + parser.getAllDynamically(ImportType.App, map, context.getStaticPackages()) + } + + override val flow = channelFlow { + send(null) + send(loadExtensions()) + suspendCoroutine { + val receiver = Receiver { + scope.launch { + send(loadExtensions()) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_CHANGED) + addDataScheme("package") + } + ContextCompat.registerReceiver( + context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + } + + + class Receiver(private val onReceive: () -> Unit) : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) = onReceive() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/CombinedRepository.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/CombinedRepository.kt new file mode 100644 index 0000000..39d3e6e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/CombinedRepository.kt @@ -0,0 +1,32 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.models.Metadata +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import java.io.File + +class CombinedRepository( + scope: CoroutineScope, + context: Context, + fileIgnoreFlow: Flow, + extensionParser: ExtensionParser, + vararg builtIns: Pair> +) : ExtensionRepository { + + private val list = builtIns.map { Result.success(it) } + private val appRepository = AppRepository(scope, context, extensionParser) + private val fileRepository = FileRepository(context, extensionParser, fileIgnoreFlow) + + override val flow = fileRepository.flow.combine(appRepository.flow) { file, app -> + if (app == null) return@combine null + list + file + app + }.stateIn(scope, SharingStarted.Lazily, list) + + override suspend fun loadExtensions() = flow.first { it != null } ?: list +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/DexLoader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/DexLoader.kt new file mode 100644 index 0000000..895d7f6 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/DexLoader.kt @@ -0,0 +1,73 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import android.content.Context +import android.os.Build +import dalvik.system.DexClassLoader +import dev.brahmkshatriya.echo.common.models.Metadata +import java.io.File +import java.io.FileOutputStream +import java.lang.ref.WeakReference +import java.util.zip.ZipFile + +class DexLoader( + metadata: Metadata, + context: Context, + librarySearchPath: String = + unloadLibraries(metadata, File(context.cacheDir, "libs")).absolutePath +) : DexClassLoader( + metadata.path, context.cacheDir.absolutePath, librarySearchPath, context.classLoader +) { + + private val preservedPackages = metadata.preservedPackages + override fun loadClass(name: String?, resolve: Boolean): Class<*> { + if (name != null && preservedPackages.any { name.startsWith(it) }) { + val loadedClass = classMap[name]?.get() + if (loadedClass != null) return loadedClass + val clazz = super.loadClass(name, resolve) + classMap[name] = WeakReference(clazz) + return clazz + } + return super.loadClass(name, resolve) + } + + companion object { + private val classMap = mutableMapOf>>() + + private fun unloadLibraries( + metadata: Metadata, libFolder: File + ): File { + val targetAbi = Build.SUPPORTED_ABIS.first() + if (!libFolder.exists()) libFolder.mkdirs() + val libs = File(libFolder, metadata.id) + val version = File(libs, "version.txt") + if (version.exists() && version.readText() == metadata.version) return libs + libs.deleteRecursively() + extractLibsFromApk(metadata.path, targetAbi, libs).getOrThrow() + version.writeText(metadata.version) + return libs + } + + private fun extractLibsFromApk( + apkPath: String, targetAbi: String, outputFolder: File + ) = runCatching { + outputFolder.mkdirs() + val apkFile = ZipFile(apkPath) + val extractedFiles = mutableListOf() + apkFile.entries().iterator().forEach { entry -> + if (entry.name.startsWith("lib/$targetAbi/") && entry.name.endsWith(".so")) { + val fileName = entry.name.substringAfterLast("/") + val outputFile = File(outputFolder, fileName) + apkFile.getInputStream(entry).use { input -> + FileOutputStream(outputFile).use { output -> + input.copyTo(output) + } + } + extractedFiles.add(outputFile) + } + } + apkFile.close() + extractedFiles + } + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionParser.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionParser.kt new file mode 100644 index 0000000..1e8acde --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionParser.kt @@ -0,0 +1,101 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import android.content.Context +import android.content.pm.FeatureInfo +import android.content.pm.PackageManager +import android.os.Build +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import dev.brahmkshatriya.echo.extensions.exceptions.ExtensionLoaderException +import dev.brahmkshatriya.echo.utils.ShaUtils.getSha256 +import java.io.File +import java.util.WeakHashMap + +class ExtensionParser( + private val context: Context, +) { + + fun getAllDynamically( + type: ImportType, + map: WeakHashMap>>>>, + files: List + ): List>>> { + val new = files.associate { + val sha = runCatching { getSha256(it) }.getOrNull().orEmpty() + val entry = map[it.absolutePath] + val value = if (entry != null && entry.first == sha) entry + else sha to parse(it, type) + it.absolutePath to value + } + map.clear() + map.putAll(new) + return map.values.map { it.second } + } + + private fun parse(source: File, importType: ImportType) = runCatching { + runCatching { + val metadata = parseManifest(source, importType) + val lazy = lazy { loadFrom(metadata) } + metadata to lazy + }.getOrElse { + throw ExtensionLoaderException(javaClass.simpleName, source.toString(), it) + } + } + + + fun parseManifest(file: File, importType: ImportType): Metadata { + val packageInfo = + context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS) + ?: error("Failed to get package info for ${file.absolutePath}") + val metadata = packageInfo.applicationInfo!!.metaData!! + val type = packageInfo.reqFeatures!!.toExtensionType() + fun getOrNull(key: String) = metadata.getString(key)?.takeIf { it.isNotBlank() } + fun get(key: String) = getOrNull(key) + ?: error("$key not found in Metadata for ${packageInfo.packageName}") + return Metadata( + path = file.absolutePath, + preservedPackages = getOrNull("preserved_packages") + .orEmpty().split(",").mapNotNull { it.trim().ifEmpty { null } }, + className = get("class"), + importType = importType, + type = type, + id = get("id"), + version = get("version"), + icon = getOrNull("icon_url")?.toImageHolder(), + name = get("name"), + description = get("description"), + author = get("author"), + authorUrl = getOrNull("author_url"), + repoUrl = getOrNull("repo_url"), + updateUrl = getOrNull("update_url"), + isEnabled = metadata.getBoolean("enabled", true) + ) + } + + private fun loadFrom(metadata: Metadata): ExtensionClient { + val dexLoader = DexLoader(metadata, context) + val clazz = dexLoader.loadClass(metadata.className) + return clazz.getConstructor().newInstance() as ExtensionClient + } + + companion object { + + @Suppress("Deprecation") + val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or + PackageManager.GET_META_DATA or + PackageManager.GET_SIGNATURES or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0 + + const val FEATURE = "dev.brahmkshatriya.echo." + private fun Array.toExtensionType(): ExtensionType { + val feature = first { it.name.startsWith(FEATURE) } + val type = feature.name.substringAfter(FEATURE) + return ExtensionType.entries.first { it.feature == type } + } + + + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionRepository.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionRepository.kt new file mode 100644 index 0000000..a5d2ad5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/ExtensionRepository.kt @@ -0,0 +1,10 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.models.Metadata +import kotlinx.coroutines.flow.Flow + +interface ExtensionRepository { + val flow: Flow>>>?> + suspend fun loadExtensions(): List>>> +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/FileRepository.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/FileRepository.kt new file mode 100644 index 0000000..20bd682 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/extensions/repo/FileRepository.kt @@ -0,0 +1,54 @@ +package dev.brahmkshatriya.echo.extensions.repo + +import android.content.Context +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Metadata +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.util.WeakHashMap + +class FileRepository( + private val folder: File, + private val parser: ExtensionParser, + private val fileIgnoreFlow: Flow +) : ExtensionRepository { + + private val map = + WeakHashMap>>>>() + private val mutex = Mutex() + + private var toIgnoreFile: File? = null + override val flow = channelFlow { + send(loadExtensions()) + fileIgnoreFlow.collectLatest { + toIgnoreFile = it + send(loadExtensions()) + } + }.flowOn(Dispatchers.IO) + + private fun loadAllApks() = folder.run { + setReadOnly() + listFiles()!!.filter { + it != toIgnoreFile && it.extension == "apk" + }.onEach { it.setWritable(false) } + } + + override suspend fun loadExtensions() = mutex.withLock { + parser.getAllDynamically(ImportType.File, map, loadAllApks()) + } + + constructor( + context: Context, parser: ExtensionParser, fileIgnoreFlow: Flow + ) : this(context.getExtensionsFileDir(), parser, fileIgnoreFlow) + + companion object { + fun Context.getExtensionsFileDir() = File(filesDir, "extensions").apply { mkdirs() } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/AndroidAutoCallback.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/AndroidAutoCallback.kt new file mode 100644 index 0000000..dd26ad8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/AndroidAutoCallback.kt @@ -0,0 +1,438 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.ContentResolver +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import androidx.annotation.CallSuper +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionError +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.clients.LibraryFeedClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.SearchFeedClient +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.builtin.offline.OfflineExtension +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import dev.brahmkshatriya.echo.utils.CoroutineUtils.await +import dev.brahmkshatriya.echo.utils.CoroutineUtils.future +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import java.util.WeakHashMap + +@UnstableApi +abstract class AndroidAutoCallback( + open val app: App, + open val scope: CoroutineScope, + open val extensionList: StateFlow>, + open val downloadFlow: StateFlow> +) : MediaLibrarySession.Callback { + + val context get() = app.context + + @CallSuper + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture( + LibraryResult.ofItem(browsableItem(ROOT, "", browsable = false), null) + ) + } + + @OptIn(UnstableApi::class) + @CallSuper + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture>> = scope.future { + val extensions = extensionList.value + if (parentId == ROOT) return@future LibraryResult.ofItemList( + extensions.map { it.toMediaItem(context) }, + null + ) + val extId = parentId.substringAfter("$ROOT/").substringBefore("/") + val extension = extensions.first { it.id == extId } + val searchQuery = params?.extras?.getString("search_query") ?: "" + val type = parentId.substringAfter("$extId/").substringBefore("/") + when (type) { + ALBUM -> extension.getList { + val id = parentId.substringAfter("$ALBUM/").substringBefore("/") + val unloaded = itemMap[id] as Album + getTracks(context, id, page) { + val album = loadAlbum(unloaded) + album to loadTracks(album) + } + } + + PLAYLIST -> extension.getList { + val id = parentId.substringAfter("$PLAYLIST/").substringBefore("/") + val unloaded = itemMap[id] as Playlist + getTracks(context, id, page) { + val playlist = loadPlaylist(unloaded) + playlist to loadTracks(playlist) + } + } + + RADIO -> extension.getList { + val id = parentId.substringAfter("$RADIO/").substringBefore("/") + val radio = itemMap[id] as Radio + getTracks(context, id, page) { + radio to loadTracks(radio) + } + } + + ARTIST -> extension.getList { + val id = parentId.substringAfter("$ARTIST/").substringBefore("/") + val artist = loadArtist(Artist(id, "")) + loadFeed(artist).toMediaItems(artist.id, context, extId, page) + } + + LIST -> extension.getList { + val id = parentId.substringAfter("$LIST/").substringBefore("/") + getListsItems(context, id, extId) + } + + SHELF -> extension.getList { + val id = parentId.substringAfter("$SHELF/").substringBefore("/") + getShelfItems(context, id, extId, page) + } + + HOME -> extension.getFeed( + context, parentId, HOME, page + ) { loadHomeFeed() } + + LIBRARY -> extension.getFeed( + context, parentId, LIBRARY, page + ) { loadLibraryFeed() } + + SEARCH -> extension.getFeed( + context, parentId, SEARCH, page + ) { loadSearchFeed(searchQuery) } + + else -> LibraryResult.ofItemList( + listOfNotNull( + if (extension.isClient()) + browsableItem("$ROOT/$extId/$HOME", context.getString(R.string.home)) + else null, + if (extension.isClient()) + browsableItem("$ROOT/$extId/$SEARCH", context.getString(R.string.search)) + else null, + if (extension.isClient()) + browsableItem("$ROOT/$extId/$LIBRARY", context.getString(R.string.library)) + else null, + ), + null + ) + } + } + + @OptIn(UnstableApi::class) + @CallSuper + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture>> { + return scope.future { + val extensions = extensionList.value + LibraryResult.ofItemList( + extensions.map { ext -> + browsableItem("$ROOT/${ext.id}/$SEARCH", ext.name, query) + }, + MediaLibraryService.LibraryParams.Builder() + .setExtras(bundleOf("search_query" to query)) + .build() + ) + } + } + + @CallSuper + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ) = scope.future { + val new = mediaItems.mapNotNull { + if (it.mediaId.startsWith("auto/")) { + val id = it.mediaId.substringAfter("auto/") + val (track, extId, con) = + context.getFromCache>(id, "auto") + ?: return@mapNotNull null + MediaItemUtils.build( + app, + downloadFlow.value, + MediaState.Unloaded(extId, track), + con + ) + } else it + } + val future = super.onSetMediaItems( + mediaSession, controller, new, startIndex, startPositionMs + ) + future.await(context) + } + + companion object { + private const val ROOT = "root" + private const val LIBRARY = "library" + private const val HOME = "home" + private const val SEARCH = "search" + private const val FEED = "feed" + private const val SHELF = "shelf" + private const val LIST = "list" + + private const val ARTIST = "artist" + private const val USER = "user" + private const val ALBUM = "album" + private const val PLAYLIST = "playlist" + private const val RADIO = "radio" + + private fun Resources.getUri(int: Int): Uri { + val scheme = ContentResolver.SCHEME_ANDROID_RESOURCE + val pkg = getResourcePackageName(int) + val type = getResourceTypeName(int) + val name = getResourceEntryName(int) + val uri = "$scheme://$pkg/$type/$name" + return uri.toUri() + } + + private fun ImageHolder.toUri(context: Context) = when (this) { + is ImageHolder.ResourceUriImageHolder -> uri.toUri() + is ImageHolder.NetworkRequestImageHolder -> request.url.toUri() + is ImageHolder.ResourceIdImageHolder -> context.resources.getUri(resId) + is ImageHolder.HexColorImageHolder -> "".toUri() + } + + private fun browsableItem( + id: String, + title: String, + subtitle: String? = null, + browsable: Boolean = true, + artWorkUri: Uri? = null, + type: Int = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED + ) = MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(false) + .setIsBrowsable(browsable) + .setMediaType(type) + .setTitle(title) + .setSubtitle(subtitle) + .setArtworkUri(artWorkUri) + .build() + ) + .build() + + private fun Track.toItem( + context: Context, extensionId: String, con: EchoMediaItem? = null + ): MediaItem { + context.saveToCache(id, Triple(this, extensionId, con), "auto") + return MediaItem.Builder() + .setMediaId("auto/$id") + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .setTitle(title) + .setArtist(subtitleWithE) + .setAlbumTitle(album?.title) + .setArtworkUri(cover?.toUri(context)) + .build() + ).build() + } + + private suspend fun Extension<*>.toMediaItem(context: Context) = browsableItem( + "$ROOT/$id", name, context.getString(R.string.extension), + instance.value().isSuccess, + metadata.icon?.toUri(context) + ) + + @OptIn(UnstableApi::class) + val notSupported = + LibraryResult.ofError>(SessionError.ERROR_NOT_SUPPORTED) + + @OptIn(UnstableApi::class) + val errorIo = LibraryResult.ofError>(SessionError.ERROR_IO) + + suspend inline fun Extension<*>.getList( + block: C.() -> List + ): LibraryResult> = runCatching { + val client = instance.value().getOrThrow() as? C ?: return@runCatching notSupported + LibraryResult.ofItemList( + client.block(), + MediaLibraryService.LibraryParams.Builder() + .setOffline(client is OfflineExtension) + .build() + ) + }.getOrElse { + it.printStackTrace() + errorIo + } + + + private val itemMap = WeakHashMap() + private fun EchoMediaItem.toMediaItem( + context: Context, extId: String + ): MediaItem = when (this) { + is Track -> toItem(context, extId) + else -> { + val id = hashCode().toString() + itemMap[id] = this + val (page, type) = when (this) { + is Artist, is Radio -> USER to MediaMetadata.MEDIA_TYPE_MIXED + is Album -> ALBUM to MediaMetadata.MEDIA_TYPE_ALBUM + is Playlist -> PLAYLIST to MediaMetadata.MEDIA_TYPE_PLAYLIST + else -> throw IllegalStateException("Invalid type") + } + browsableItem( + "$ROOT/$extId/$page/$id", + title, + subtitleWithE, + true, + cover?.toUri(context), + type + ) + } + } + + private val listsMap = WeakHashMap>() + private fun getListsItems( + context: Context, id: String, extId: String + ) = run { + val shelf = listsMap[id]!! + when (shelf) { + is Shelf.Lists.Categories -> shelf.list.map { it.toMediaItem(context, extId) } + is Shelf.Lists.Items -> shelf.list.map { it.toMediaItem(context, extId) } + is Shelf.Lists.Tracks -> shelf.list.map { it.toItem(context, extId) } + } + listOfNotNull( + if (shelf.more != null) { + val moreId = shelf.id + feedMap[moreId] = shelf.more + browsableItem( + "$ROOT/$extId/$FEED/$moreId", + context.getString(R.string.more) + ) + } else null + ) + } + + private fun Shelf.toMediaItem( + context: Context, extId: String + ): MediaItem = when (this) { + is Shelf.Category -> { + val items = feed + if (items != null) feedMap[id] = items + browsableItem("$ROOT/$extId/$FEED/$id", title, subtitle, items != null) + } + + is Shelf.Item -> media.toMediaItem(context, extId) + is Shelf.Lists<*> -> { + val id = "${id.hashCode()}" + listsMap[id] = this + browsableItem("$ROOT/$extId/$LIST/$id", title, subtitle) + } + } + + + // THIS PROBABLY BREAKS GOING BACK TBH, NEED TO TEST + private val shelvesMap = WeakHashMap>() + private val continuations = WeakHashMap, String?>() + private suspend fun getShelfItems( + context: Context, id: String, extId: String, page: Int + ): List { + val shelf = shelvesMap[id]!! + val (list, next) = shelf.loadPage(continuations[id to page]) + continuations[id to page + 1] = next + return listOfNotNull( + *list.map { it.toMediaItem(context, extId) }.toTypedArray() + ) + } + + private val feedMap = WeakHashMap>() + private suspend fun Feed.toMediaItems( + id: String, context: Context, extId: String, page: Int + ): List { + val id = "${id.hashCode()}" + feedMap[id] = this + //TODO + return listOf() + } + + private suspend inline fun Extension<*>.getFeed( + context: Context, + parentId: String, + page: String, + pageNumber: Int, + getFeed: T.() -> Feed + ) = getList { + TODO() + } + + private val tracksMap = WeakHashMap>>() + private suspend fun getTracks( + context: Context, + id: String, + page: Int, + getTracks: suspend () -> Pair?> + ): List { + val (item, tracks) = tracksMap.getOrPut(id) { + val tracks = getTracks().run { + first to (second?.run { getPagedData(tabs.firstOrNull()) }?.pagedData + ?: PagedData.empty()) + } + tracksMap[id] = tracks + tracks + } + val (list, next) = tracks.loadPage(continuations[id to page]) + continuations[id to page + 1] = next + return list.map { it.toItem(context, id, item) } + } + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt new file mode 100644 index 0000000..a415545 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt @@ -0,0 +1,275 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes +import androidx.media3.common.ThumbRating +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.selectServerIndex +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.serialization.Serializable +import kotlin.io.encoding.Base64 +import kotlin.text.Charsets.UTF_8 + +object MediaItemUtils { + + fun build( + app: App, + downloads: List, + state: MediaState.Unloaded, + context: EchoMediaItem?, + ): MediaItem { + val item = MediaItem.Builder() + val metadata = state.toMetaData(bundleOf(), downloads, context, false, app) + item.setMediaMetadata(metadata) + item.setMediaId(state.item.id) + item.setUri(state.item.id) + return item.build() + } + + fun buildLoaded( + app: App, + downloads: List, + mediaItem: MediaItem, + state: MediaState.Loaded, + ): MediaItem = with(mediaItem) { + val item = buildUpon() + val metadata = state.toMetaData( + mediaMetadata.extras!!, downloads, context, true, app + ) + item.setMediaMetadata(metadata) + return item.build() + } + + fun buildServer(mediaItem: MediaItem, index: Int): MediaItem = with(mediaItem) { + val bundle = Bundle().apply { + putAll(mediaMetadata.extras!!) + putInt("serverIndex", index) + putInt("retries", 0) + } + buildWithBundle(this, bundle) + } + + fun buildSource(mediaItem: MediaItem, index: Int) = with(mediaItem) { + val bundle = Bundle().apply { + putAll(mediaMetadata.extras!!) + putInt("sourceIndex", index) + putInt("retries", 0) + } + buildWithBundle(this, bundle) + } + + fun buildBackground(mediaItem: MediaItem, index: Int): MediaItem = with(mediaItem) { + val bundle = Bundle().apply { + putAll(mediaMetadata.extras!!) + putInt("backgroundIndex", index) + } + buildWithBundle(this, bundle) + } + + fun buildSubtitle(mediaItem: MediaItem, index: Int): MediaItem = with(mediaItem) { + val bundle = Bundle().apply { + putAll(mediaMetadata.extras!!) + putInt("subtitleIndex", index) + } + buildWithBundle(this, bundle) + } + + + fun withRetry(item: MediaItem): MediaItem { + val bundle = Bundle().apply { + putAll(item.mediaMetadata.extras!!) + val retries = getInt("retries") + 1 + putBoolean("loaded", false) + putInt("retries", retries) + } + return buildWithBundle(item, bundle) + } + + private fun buildWithBundle(mediaItem: MediaItem, bundle: Bundle) = run { + val item = mediaItem.buildUpon() + val metadata = + mediaItem.mediaMetadata.buildUpon().setExtras(bundle).setSubtitle(bundle.indexes()) + .build() + item.setMediaMetadata(metadata) + item.build() + } + + @Serializable + data class Key(val trackId: String, val sourceIndex: Int, val extensionId: String) + + fun String.toKey() = runCatching { + Base64.decode(this).toString(UTF_8).toData().getOrThrow() + } + + fun buildForSource( + mediaItem: MediaItem, index: Int, source: Streamable.Source?, + ) = with(mediaItem) { + val item = buildUpon() + item.setUri(Base64.encode(Key(track.id, index, extensionId).toJson().toByteArray())) + when (val decryption = (source as? Streamable.Source.Http)?.decryption) { + null -> {} + is Streamable.Decryption.Widevine -> { + val drmRequest = decryption.license + val config = MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID) + .setLicenseUri(drmRequest.url).setMultiSession(decryption.isMultiSession) + .setLicenseRequestHeaders(drmRequest.headers).build() + item.setDrmConfiguration(config) + } + } + item.build() + } + + fun buildWithBackgroundAndSubtitle( + mediaItem: MediaItem, + background: Streamable.Media.Background?, + subtitle: Streamable.Media.Subtitle?, + ) = with(mediaItem) { + val bundle = mediaMetadata.extras!! + bundle.putSerialized("background", background) + val item = buildUpon() + item.setSubtitleConfigurations( + if (subtitle == null) listOf() + else listOf( + MediaItem.SubtitleConfiguration.Builder(subtitle.url.toUri()) + .setMimeType(subtitle.type.toMimeType()) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT).build() + ) + ) + item.build() + } + + + @OptIn(UnstableApi::class) + private fun MediaState.toMetaData( + bundle: Bundle, + downloads: List, + context: EchoMediaItem? = bundle.getSerialized("context")?.getOrNull(), + loaded: Boolean = bundle.getBoolean("loaded"), + app: App, + serverIndex: Int? = null, + backgroundIndex: Int? = null, + subtitleIndex: Int? = null, + ) = with(item) { + val isLiked = (this@toMetaData as? MediaState.Loaded<*>)?.isLiked == true + MediaMetadata.Builder() + .setTitle(title) + .setAlbumTitle(album?.title) + .setAlbumArtist(album?.artists?.joinToString(", ") { it.name }) + .setArtist(artists.joinToString(", ") { it.name }) + .setArtworkUri(cover?.toUriWithJson()) + .setUserRating( + if (isLiked) ThumbRating(true) else ThumbRating() + ) + .setExtras(Bundle().apply { + putAll(bundle) + putSerialized("unloadedCover", bundle.stateNullable?.item?.cover) + putSerialized("state", this@toMetaData) + putSerialized("context", context) + putBoolean("loaded", loaded) + putInt("subtitleIndex", subtitleIndex ?: 0.takeIf { subtitles.isNotEmpty() } ?: -1) + putInt( + "backgroundIndex", backgroundIndex ?: 0.takeIf { + backgrounds.isNotEmpty() && app.settings.showBackground() + } ?: -1 + ) + val downloaded = + downloads.filter { it.download.trackId == id } + .mapNotNull { it.download.finalFile } + putInt( + "serverIndex", + serverIndex ?: selectServerIndex(app, extensionId, servers, downloaded) + ) + putSerialized("downloaded", downloaded) + }) + .setSubtitle(bundle.indexes()) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .setIsPlayable(true) + .setIsBrowsable(false) + .build() + } + + private fun Bundle.indexes() = + "${getInt("serverIndex")} ${getInt("sourceIndex")} ${getInt("backgroundIndex")} ${getInt("subtitleIndex")}" + + private val Bundle?.stateNullable + get() = this?.getSerialized>("state")?.getOrNull() + val Bundle?.state get() = requireNotNull(stateNullable) + val Bundle?.track get() = state.item + val Bundle?.isLoaded get() = this?.getBoolean("loaded") ?: false + val Bundle?.extensionId get() = state.extensionId + val Bundle?.context get() = this?.getSerialized("context")?.getOrNull() + val Bundle?.serverIndex get() = this?.getInt("serverIndex", -1) ?: -1 + val Bundle?.sourceIndex get() = this?.getInt("sourceIndex", -1) ?: -1 + val Bundle?.backgroundIndex get() = this?.getInt("backgroundIndex", -1) ?: -1 + val Bundle?.subtitleIndex get() = this?.getInt("subtitleIndex", -1) ?: -1 + val Bundle?.background + get() = this?.getSerialized("background")?.getOrNull() + val Bundle?.retries get() = this?.getInt("retries") ?: 0 + val Bundle?.unloadedCover + get() = this?.getSerialized("unloadedCover")?.getOrNull() + val Bundle?.downloaded get() = this?.getSerialized>("downloaded")?.getOrNull() + + val MediaItem.state get() = mediaMetadata.extras.state + val MediaItem.track get() = mediaMetadata.extras.track + val MediaItem.extensionId get() = mediaMetadata.extras.extensionId + val MediaItem.context get() = mediaMetadata.extras.context + val MediaItem.isLoaded get() = mediaMetadata.extras.isLoaded + val MediaItem.serverIndex get() = mediaMetadata.extras.serverIndex + val MediaItem.sourceIndex get() = mediaMetadata.extras.sourceIndex + val MediaItem.backgroundIndex get() = mediaMetadata.extras.backgroundIndex + val MediaItem.subtitleIndex get() = mediaMetadata.extras.subtitleIndex + val MediaItem.background get() = mediaMetadata.extras.background + val MediaMetadata.isLiked get() = (userRating as? ThumbRating)?.isThumbsUp == true + val MediaItem.isLiked get() = mediaMetadata.isLiked + val MediaItem.retries get() = mediaMetadata.extras.retries + val MediaItem.unloadedCover get() = mediaMetadata.extras.unloadedCover + val MediaItem.downloaded get() = mediaMetadata.extras.downloaded + + private fun Streamable.SubtitleType.toMimeType() = when (this) { + Streamable.SubtitleType.VTT -> MimeTypes.TEXT_VTT + Streamable.SubtitleType.SRT -> MimeTypes.APPLICATION_SUBRIP + Streamable.SubtitleType.ASS -> MimeTypes.TEXT_SSA + } + + private fun ImageHolder.toUriWithJson(): Uri { + val main = when (this) { + is ImageHolder.ResourceUriImageHolder -> uri + is ImageHolder.NetworkRequestImageHolder -> request.url + is ImageHolder.ResourceIdImageHolder -> "res://$resId" + is ImageHolder.HexColorImageHolder -> "" + }.toUri() + val json = toJson() + return main.buildUpon().appendQueryParameter("actual_data", json).build() + } + + const val SHOW_BACKGROUND = "show_background" + fun SharedPreferences?.showBackground() = this?.getBoolean(SHOW_BACKGROUND, true) ?: true + + fun MediaItem.serverWithDownloads( + context: Context, + ) = track.servers + listOfNotNull( + Streamable.server( + "DOWNLOADED", Int.MAX_VALUE, context.getString(R.string.downloads) + ).takeIf { !downloaded.isNullOrEmpty() } + ) +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCallback.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCallback.kt new file mode 100644 index 0000000..849c522 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCallback.kt @@ -0,0 +1,443 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.scale +import androidx.core.os.bundleOf +import androidx.media3.common.Player +import androidx.media3.common.Rating +import androidx.media3.common.ThumbRating +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaButtonReceiver +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionError +import androidx.media3.session.SessionResult +import androidx.media3.session.SessionResult.RESULT_SUCCESS +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.ArtistClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed.Companion.pagedDataOfFirst +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.get +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverPlaylist +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverRepeat +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverShuffle +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverTracks +import dev.brahmkshatriya.echo.playback.exceptions.PlayerException +import dev.brahmkshatriya.echo.playback.listener.PlayerRadio +import dev.brahmkshatriya.echo.utils.CoroutineUtils.future +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadDrawable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +@OptIn(UnstableApi::class) +class PlayerCallback( + override val app: App, + override val scope: CoroutineScope, + private val throwableFlow: MutableSharedFlow, + private val extensions: ExtensionLoader, + private val radioFlow: MutableStateFlow, + override val downloadFlow: StateFlow>, +) : AndroidAutoCallback(app, scope, extensions.music, downloadFlow) { + + override fun onConnect( + session: MediaSession, controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult { + val sessionCommands = with(PlayerCommands) { + MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + .add(likeCommand).add(unlikeCommand).add(repeatCommand).add(repeatOffCommand) + .add(repeatOneCommand).add(radioCommand).add(sleepTimer) + .add(playCommand).add(addToQueueCommand).add(addToNextCommand) + .add(resumeCommand).add(imageCommand) + .build() + } + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands).build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture = with(PlayerCommands) { + val player = session.player + when (customCommand) { + likeCommand -> onSetRating(session, controller, ThumbRating(true)) + unlikeCommand -> onSetRating(session, controller, ThumbRating()) + repeatOffCommand -> setRepeat(player, Player.REPEAT_MODE_OFF) + repeatOneCommand -> setRepeat(player, Player.REPEAT_MODE_ONE) + repeatCommand -> setRepeat(player, Player.REPEAT_MODE_ALL) + playCommand -> playItem(player, args) + addToQueueCommand -> addToQueue(player, args) + addToNextCommand -> addToNext(player, args) + radioCommand -> radio(player, args) + sleepTimer -> onSleepTimer(player, args.getLong("ms")) + resumeCommand -> resume(player, args.getBoolean("cleared", true)) + imageCommand -> getImage(player) + else -> super.onCustomCommand(session, controller, customCommand, args) + } + } + + private fun getImage(player: Player) = scope.future { + val item = player.with { currentMediaItem } + ?: context.recoverPlaylist(app, downloadFlow.value, false).run { first.getOrNull(second) } + ?: return@future SessionResult(SessionError.ERROR_UNKNOWN) + val image = item.track.cover.loadDrawable(context)?.toScaledBitmap(720) + SessionResult(RESULT_SUCCESS, Bundle().apply { putParcelable("image", image) }) + } + + private fun Drawable.toScaledBitmap(width: Int) = toBitmap().let { bmp -> + val ratio = width.toFloat() / bmp.width + val height = (bmp.height * ratio).toInt() + bmp.scale(width, height) + } + + private fun resume(player: Player, withClear: Boolean) = scope.future { + withContext(Dispatchers.Main) { + player.shuffleModeEnabled = context.recoverShuffle() == true + player.repeatMode = context.recoverRepeat() ?: Player.REPEAT_MODE_OFF + } + val (items, index, pos) = context.recoverPlaylist(app,downloadFlow.value, withClear) + withContext(Dispatchers.Main) { + player.setMediaItems(items, index, pos) + player.prepare() + } + SessionResult(RESULT_SUCCESS) + } + + private var timerJob: Job? = null + private fun onSleepTimer(player: Player, ms: Long): ListenableFuture { + timerJob?.cancel() + val time = when (ms) { + 0L -> return Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + Long.MAX_VALUE -> player.run { duration - currentPosition } + else -> ms + } + + timerJob = scope.launch { + delay(time) + player.with { pause() } + } + return Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + } + + private fun setRepeat(player: Player, repeat: Int) = run { + player.repeatMode = repeat + Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + } + + + @OptIn(UnstableApi::class) + private fun radio(player: Player, args: Bundle) = scope.future { + val error = SessionResult(SessionError.ERROR_UNKNOWN) + val extId = args.getString("extId") ?: return@future error + val item = args.getSerialized("item")?.getOrNull() ?: return@future error + val itemLoaded = args.getBoolean("loaded", false) + val extension = extensions.music.getExtension(extId) ?: return@future error + val newItem = if (itemLoaded) item else loadItem(extension, item) + radioFlow.value = PlayerState.Radio.Loading + val loaded = PlayerRadio.start( + throwableFlow, extension, newItem, null + ) + if (loaded == null) return@future error + player.with { + clearMediaItems() + shuffleModeEnabled = false + } + PlayerRadio.play(player, downloadFlow, app, radioFlow, loaded) + player.with { play() } + SessionResult(RESULT_SUCCESS) + } + + private suspend fun loadItem( + extension: Extension<*>, item: EchoMediaItem, + ) = when (item) { + is Track -> extension.getAs { loadTrack(item, false) } + is Album -> extension.getAs { loadAlbum(item) } + is Playlist -> extension.getAs { loadPlaylist(item) } + is Artist -> extension.getAs { loadArtist(item) } + is Radio -> throw IllegalStateException() + }.getOrThrow() + + private suspend fun listTracks( + extension: Extension<*>, item: EchoMediaItem, loaded: Boolean, + ) = when (item) { + is Album -> extension.getAs> { + val album = if (!loaded) loadAlbum(item) else item + loadTracks(album)?.pagedDataOfFirst() + ?: PagedData.empty() + } + + is Playlist -> extension.getAs> { + val playlist = if (!loaded) loadPlaylist(item) else item + loadTracks(playlist).pagedDataOfFirst() + } + + is Radio -> extension.getAs> { + val radio = if (!loaded) loadRadio(item) else item + loadTracks(radio).pagedDataOfFirst() + } + + is Artist -> extension.getAs> { + val artist = if (!loaded) loadArtist(item) else item + loadFeed(artist).pagedDataOfFirst().toTracks() + } + + is Track -> Result.success(PagedData.Single { listOf(item) }) + } + + + private fun playItem(player: Player, args: Bundle) = scope.future { + val error = SessionResult(SessionError.ERROR_UNKNOWN) + val extId = args.getString("extId") ?: return@future error + val item = args.getSerialized("item")?.getOrNull() ?: return@future error + val loaded = args.getBoolean("loaded", false) + val shuffle = args.getBoolean("shuffle", false) + val extension = extensions.music.getExtension(extId) ?: return@future error + when (item) { + is Track -> { + val mediaItem = MediaItemUtils.build( + app, downloadFlow.value, MediaState.Unloaded(extId, item), null + ) + player.with { + setMediaItem(mediaItem) + prepare() + seekTo(item.playedDuration ?: 0) + play() + } + } + + else -> { + val tracks = listTracks(extension, item, loaded).getOrElse { + throwableFlow.emit(it) + return@future error + } + + val result = if (shuffle) extension.get { tracks.loadAll() } + else runCatching { + val (list, continuation) = extension.get { tracks.loadPage(null) }.getOrThrow() + if (continuation != null) scope.launch { + val all = extension.get { tracks.loadAll() }.getOrElse { + throwableFlow.emit(it) + return@launch + }.drop(list.size).map { + MediaItemUtils.build( + app, downloadFlow.value, MediaState.Unloaded(extId, it), item + ) + } + player.with { addMediaItems(list.size, all) } + } + list + } + val list = result.getOrElse { + throwableFlow.emit(it) + return@future error + } + player.with { + setMediaItems(list.map { + MediaItemUtils.build( + app, downloadFlow.value, MediaState.Unloaded(extId, it), item + ) + }) + shuffleModeEnabled = shuffle + seekTo(0, list.firstOrNull()?.playedDuration ?: 0) + play() + } + } + } + SessionResult(RESULT_SUCCESS) + } + + private suspend fun Player.with(block: suspend Player.() -> T): T = + withContext(Dispatchers.Main) { block() } + + private suspend fun PagedData.load( + pages: Int = 5, + ) = runCatching { + val list = mutableListOf() + var page = loadPage(null) + list.addAll(page.data) + var count = 0 + while (page.continuation != null && count < pages) { + page = loadPage(page.continuation) + list.addAll(page.data) + count++ + } + list + } + + private fun addToQueue(player: Player, args: Bundle) = scope.future { + val error = SessionResult(SessionError.ERROR_UNKNOWN) + val extId = args.getString("extId") ?: return@future error + val item = args.getSerialized("item")?.getOrNull() ?: return@future error + val loaded = args.getBoolean("loaded", false) + val extension = extensions.music.getExtension(extId) ?: return@future error + val tracks = listTracks(extension, item, loaded).getOrElse { + throwableFlow.emit(it) + return@future error + }.load().getOrElse { + throwableFlow.emit(it) + return@future error + } + if (tracks.isEmpty()) return@future error + val mediaItems = tracks.map { track -> + MediaItemUtils.build( + app, + downloadFlow.value, + MediaState.Unloaded(extId, track), + null + ) + } + player.with { + addMediaItems(mediaItems) + prepare() + } + SessionResult(RESULT_SUCCESS) + } + + private var next = 0 + private var nextJob: Job? = null + private fun addToNext(player: Player, args: Bundle) = scope.future { + val error = SessionResult(SessionError.ERROR_UNKNOWN) + val extId = args.getString("extId") ?: return@future error + val item = args.getSerialized("item")?.getOrNull() ?: return@future error + val loaded = args.getBoolean("loaded", false) + val extension = extensions.music.getExtension(extId) ?: return@future error + nextJob?.cancel() + val tracks = listTracks(extension, item, loaded).getOrElse { + throwableFlow.emit(it) + return@future error + }.load().getOrElse { + throwableFlow.emit(it) + return@future error + } + if (tracks.isEmpty()) return@future error + val mediaItems = tracks.map { track -> + MediaItemUtils.build( + app, + downloadFlow.value, + MediaState.Unloaded(extId, track), + null + ) + } + player.with { + if (mediaItemCount == 0) playWhenReady = true + addMediaItems(currentMediaItemIndex + 1 + next, mediaItems) + prepare() + } + next += mediaItems.size + nextJob = scope.launch { + delay(5000) + next = 0 + } + SessionResult(RESULT_SUCCESS) + } + + override fun onSetRating( + session: MediaSession, controller: MediaSession.ControllerInfo, rating: Rating, + ): ListenableFuture { + return if (rating !is ThumbRating) super.onSetRating(session, controller, rating) + else scope.future { + val item = session.player.with { currentMediaItem } + ?: return@future SessionResult(SessionError.ERROR_UNKNOWN) + val track = item.track + runCatching { + val extension = extensions.music.getExtensionOrThrow(item.extensionId) + extension.getAs { + likeItem(track, rating.isThumbsUp) + } + }.getOrElse { + throwableFlow.emit(PlayerException(item, it)) + return@future SessionResult(SessionError.ERROR_UNKNOWN) + } + val liked = rating.isThumbsUp + val newItem = item.run { + buildUpon().setMediaMetadata( + mediaMetadata.buildUpon().setUserRating(ThumbRating(liked)).build() + ) + }.build() + session.player.with { + replaceMediaItem(currentMediaItemIndex, newItem) + } + SessionResult(RESULT_SUCCESS, bundleOf("liked" to liked)) + } + } + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + ) = scope.future { + withContext(Dispatchers.Main) { + mediaSession.player.shuffleModeEnabled = context.recoverShuffle() ?: false + mediaSession.player.repeatMode = context.recoverRepeat() ?: Player.REPEAT_MODE_OFF + } + val (items, index, pos) = context.recoverPlaylist(app, downloadFlow.value) + MediaItemsWithStartPosition(items, index, pos) + } + + class ButtonReceiver : MediaButtonReceiver() { + override fun shouldStartForegroundService(context: Context, intent: Intent): Boolean { + val isEmpty = context.recoverTracks().isNullOrEmpty() + if (isEmpty) Toast.makeText( + context, + context.getString(R.string.no_last_played_track_found), + Toast.LENGTH_SHORT + ).show() + return !isEmpty + } + } + + companion object { + fun PagedData.toTracks() = map { + it.getOrThrow().mapNotNull { shelf -> + when (shelf) { + is Shelf.Category -> null + is Shelf.Item -> listOfNotNull(shelf.media as? Track) + is Shelf.Lists.Categories -> null + is Shelf.Lists.Items -> shelf.list.filterIsInstance() + is Shelf.Lists.Tracks -> shelf.list + } + }.flatten() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt new file mode 100644 index 0000000..cc3ca86 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt @@ -0,0 +1,59 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.CommandButton +import androidx.media3.session.SessionCommand +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLiked + +object PlayerCommands { + val likeCommand = SessionCommand("liked", Bundle.EMPTY) + val unlikeCommand = SessionCommand("unliked", Bundle.EMPTY) + val repeatCommand = SessionCommand("repeat", Bundle.EMPTY) + val repeatOffCommand = SessionCommand("repeat_off", Bundle.EMPTY) + val repeatOneCommand = SessionCommand("repeat_one", Bundle.EMPTY) + val playCommand = SessionCommand("play", Bundle.EMPTY) + val addToQueueCommand = SessionCommand("add_to_queue", Bundle.EMPTY) + val addToNextCommand = SessionCommand("add_to_next", Bundle.EMPTY) + val radioCommand = SessionCommand("radio", Bundle.EMPTY) + val sleepTimer = SessionCommand("sleep_timer", Bundle.EMPTY) + val resumeCommand = SessionCommand("resume", Bundle.EMPTY) + val imageCommand = SessionCommand("image", Bundle.EMPTY) + + fun getLikeButton(context: Context, item: MediaItem) = run { + val builder = CommandButton.Builder() + if (!item.isLiked) builder + .setDisplayName(context.getString(R.string.like)) + .setIconResId(R.drawable.ic_favorite_20dp) + .setSessionCommand(likeCommand) + else builder + .setDisplayName(context.getString(R.string.unlike)) + .setIconResId(R.drawable.ic_favorite_filled_20dp) + .setSessionCommand(unlikeCommand) + builder.build() + } + + fun getRepeatButton(context: Context, repeat: Int) = run { + val builder = CommandButton.Builder() + when (repeat) { + Player.REPEAT_MODE_ONE -> builder + .setDisplayName(context.getString(R.string.repeat_one)) + .setIconResId(R.drawable.ic_repeat_one_20dp) + .setSessionCommand(repeatOffCommand) + + Player.REPEAT_MODE_OFF -> builder + .setDisplayName(context.getString(R.string.repeat_off)) + .setIconResId(R.drawable.ic_repeat_20dp) + .setSessionCommand(repeatCommand) + + else -> builder + .setDisplayName(context.getString(R.string.repeat_all)) + .setIconResId(R.drawable.ic_repeat_on_20dp) + .setSessionCommand(repeatOneCommand) + } + builder.build() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerService.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerService.kt new file mode 100644 index 0000000..34b323e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerService.kt @@ -0,0 +1,292 @@ +package dev.brahmkshatriya.echo.playback + +import android.app.Activity +import android.app.Application +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionToken +import dev.brahmkshatriya.echo.MainActivity.Companion.getMainActivity +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.extensionPrefId +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.prefs +import dev.brahmkshatriya.echo.playback.listener.AudioFocusListener +import dev.brahmkshatriya.echo.playback.listener.EffectsListener +import dev.brahmkshatriya.echo.playback.listener.MediaSessionServiceListener +import dev.brahmkshatriya.echo.playback.listener.PlayerEventListener +import dev.brahmkshatriya.echo.playback.listener.PlayerRadio +import dev.brahmkshatriya.echo.playback.listener.TrackingListener +import dev.brahmkshatriya.echo.playback.renderer.PlayerBitmapLoader +import dev.brahmkshatriya.echo.playback.renderer.RenderersFactory +import dev.brahmkshatriya.echo.playback.source.StreamableMediaSource +import dev.brahmkshatriya.echo.utils.ContextUtils.listenFuture +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koin.android.ext.android.inject +import java.io.File + +class PlayerService : MediaLibraryService() { + + private val extensionLoader by inject() + private val extensions by lazy { extensionLoader } + private val exoPlayer by lazy { createExoplayer() } + + private var mediaSession: MediaLibrarySession? = null + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + private val app by inject() + private val state by inject() + private val scope = CoroutineScope(Dispatchers.IO) + CoroutineName("PlayerService") + + @OptIn(UnstableApi::class) + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + when (key) { + SKIP_SILENCE -> exoPlayer.skipSilenceEnabled = prefs.getBoolean(key, true) + MORE_BRAIN_CAPACITY -> exoPlayer.trackSelectionParameters = + exoPlayer.trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences(offloadPreferences(prefs.getBoolean(key, false))) + .build() + } + } + private val effects by lazy { EffectsListener(exoPlayer, this, state.session) } + + private val downloader by inject() + private val downloadFlow by lazy { downloader.flow } + + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + setListener(MediaSessionServiceListener(this, getPendingIntent(this))) + + val player = ShufflePlayer(exoPlayer) + scope.launch(Dispatchers.Main) { + mediaChangeFlow.collect { (o, n) -> player.onMediaItemChanged(o, n) } + } + + val callback = PlayerCallback( + app, scope, app.throwFlow, extensions, state.radio, downloadFlow + ) + + val session = MediaLibrarySession.Builder(this, player, callback) + .setBitmapLoader(PlayerBitmapLoader(this, scope)) + .setSessionActivity(getPendingIntent(this)) + .build() + + player.addListener(AudioFocusListener(this, player)) + player.addListener( + PlayerEventListener(this, scope, session, state.current, extensions, app.throwFlow) + ) + player.addListener( + PlayerRadio( + app, scope, player, app.throwFlow, state.radio, extensions.music, downloadFlow + ) + ) + player.addListener( + TrackingListener(player, scope, extensions, state.current, app.throwFlow) + ) + player.addListener(effects) + app.settings.registerOnSharedPreferenceChangeListener(listener) + + val notificationProvider = DefaultMediaNotificationProvider.Builder(this) + .setChannelName(R.string.app_name) + .build() + notificationProvider.setSmallIcon(R.drawable.ic_mono) + setMediaNotificationProvider(notificationProvider) + + mediaSession = session + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + + private val cache by inject() + + private val mediaChangeFlow = MutableSharedFlow>() + + @OptIn(UnstableApi::class) + private fun offloadPreferences(moreBrainCapacity: Boolean) = + TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode( + if (moreBrainCapacity) AUDIO_OFFLOAD_MODE_DISABLED else AUDIO_OFFLOAD_MODE_ENABLED + ).setIsGaplessSupportRequired(true) + .setIsSpeedChangeSupportRequired(true) + .build() + + @OptIn(UnstableApi::class) + private fun createExoplayer() = run { + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + + val audioOffloadPreferences = + offloadPreferences(app.settings.getBoolean(MORE_BRAIN_CAPACITY, false)) + + val factory = StreamableMediaSource.Factory( + app, scope, state, extensions, cache, downloadFlow, mediaChangeFlow + ) + + ExoPlayer.Builder(this, factory) + .setRenderersFactory(RenderersFactory(this)) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes(audioAttributes, true) + .build() + .also { + it.trackSelectionParameters = it.trackSelectionParameters + .buildUpon() + .setAudioOffloadPreferences(audioOffloadPreferences) + .build() + it.preloadConfiguration = ExoPlayer.PreloadConfiguration(C.TIME_UNSET) + it.skipSilenceEnabled = app.settings.getBoolean(SKIP_SILENCE, true) + } + } + + + override fun onTaskRemoved(rootIntent: Intent?) { + val stopPlayer = app.settings.getBoolean(CLOSE_PLAYER, false) + val player = mediaSession?.player ?: return stopSelf() + if (stopPlayer || !player.isPlaying) stopSelf() + } + + companion object { + const val MORE_BRAIN_CAPACITY = "offload" + const val CLOSE_PLAYER = "close_player" + const val SKIP_SILENCE = "skip_silence" + + const val CACHE_SIZE = "cache_size" + + @OptIn(UnstableApi::class) + fun getCache( + app: Application, + settings: SharedPreferences, + ): SimpleCache { + val databaseProvider = StandaloneDatabaseProvider(app) + val cacheSize = settings.getInt(CACHE_SIZE, 250) + return SimpleCache( + File(app.cacheDir, "exo-player"), + LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L), + databaseProvider + ) + } + + const val STREAM_QUALITY = "stream_quality" + const val UNMETERED_STREAM_QUALITY = "unmetered_stream_quality" + val streamQualities = arrayOf("highest", "medium", "lowest") + + fun selectServerIndex( + app: App, + extensionId: String, + streamables: List, + downloaded: List, + ) = if (downloaded.isNotEmpty()) streamables.size + else if (streamables.isNotEmpty()) { + val streamable = streamables.select(app, extensionId) { it.quality } + streamables.indexOf(streamable) + } else -1 + + private fun List.select( + app: App, + settings: SharedPreferences, + quality: (E) -> Int, + default: String = streamQualities[1], + ): E? { + val unmetered = if (app.isUnmetered) selectQuality( + settings.getString(UNMETERED_STREAM_QUALITY, "off"), + quality + ) else null + return unmetered ?: selectQuality( + settings.getString(STREAM_QUALITY, default), + quality + ) + } + + private fun List.selectQuality(final: String?, quality: (E) -> Int): E? { + return when (final) { + streamQualities[0] -> maxBy { quality(it) } + streamQualities[1] -> sortedBy { quality(it) }[size / 2] + streamQualities[2] -> minBy { quality(it) } + else -> null + } + } + + + fun List.select( + app: App, extensionId: String, quality: (T) -> Int, + ): T { + val extSettings = + extensionPrefId(ExtensionType.MUSIC.name, extensionId).prefs(app.context) + return select(app, extSettings, quality, "off") + ?: select(app, app.settings, quality) + ?: first() + } + + fun getController( + context: Application, + block: (MediaController) -> Unit, + ): () -> Unit { + val sessionToken = + SessionToken(context, ComponentName(context, PlayerService::class.java)) + val playerFuture = MediaController.Builder(context, sessionToken).buildAsync() + context.listenFuture(playerFuture) { result -> + val controller = result.getOrElse { + return@listenFuture it.printStackTrace() + } + block(controller) + } + return { MediaController.releaseFuture(playerFuture) } + } + + fun getPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivityOpener::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + } + + class MainActivityOpener : Activity() { + override fun onStart() { + super.onStart() + finish() + startActivity(Intent(this, getMainActivity()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("fromNotification", true) + }) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerState.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerState.kt new file mode 100644 index 0000000..f27dfa2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/PlayerState.kt @@ -0,0 +1,55 @@ +package dev.brahmkshatriya.echo.playback + +import androidx.media3.common.MediaItem +import dev.brahmkshatriya.echo.common.helpers.Page +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.WeakHashMap + +data class PlayerState( + val current: MutableStateFlow = MutableStateFlow(null), + val radio: MutableStateFlow = MutableStateFlow(Radio.Empty), + val session: MutableStateFlow = MutableStateFlow(0) +) { + + val servers: WeakHashMap> = WeakHashMap() + val serverChanged = MutableSharedFlow() + + data class Current( + val index: Int, + val mediaItem: MediaItem, + val isLoaded: Boolean, + val isPlaying: Boolean, + ) { + + val context by lazy { mediaItem.context } + val track by lazy { mediaItem.track } + fun isPlaying(id: String?): Boolean { + val same = mediaItem.mediaId == id + || context?.id == id + || track.album?.id == id + || track.artists.any { it.id == id } + return isPlaying && same + } + + companion object { + fun Current?.isPlaying(id: String?): Boolean = this?.isPlaying(id) ?: false + } + } + + sealed class Radio { + data object Empty : Radio() + data object Loading : Radio() + data class Loaded( + val clientId: String, + val context: EchoMediaItem, + val cont: String?, + val tracks: suspend (String?) -> Page? + ) : Radio() + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt new file mode 100644 index 0000000..c2604d0 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt @@ -0,0 +1,104 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object ResumptionUtils { + + private const val CLEARED = "cleared" + private const val FOLDER = "queue" + private const val TRACKS = "queue_tracks" + private const val CONTEXTS = "queue_contexts" + private const val EXTENSIONS = "queue_extensions" + private const val INDEX = "queue_index" + private const val POSITION = "position" + private const val SHUFFLE = "shuffle" + private const val REPEAT = "repeat" + + private fun Player.mediaItems() = (0 until mediaItemCount).map { getMediaItemAt(it) } + fun saveIndex(context: Context, index: Int) { + context.saveToCache(INDEX, index, FOLDER) + } + + suspend fun saveQueue(context: Context, player: Player) = withContext(Dispatchers.Main) { + val list = player.mediaItems() + context.saveToCache(CLEARED, list.isEmpty()) + if (list.isEmpty()) return@withContext + val currentIndex = player.currentMediaItemIndex + withContext(Dispatchers.IO) { + val extensionIds = list.map { it.extensionId } + val tracks = list.map { it.track } + val contexts = list.map { it.context } + context.saveToCache(INDEX, currentIndex, FOLDER) + context.saveToCache(EXTENSIONS, extensionIds, FOLDER) + context.saveToCache(TRACKS, tracks, FOLDER) + context.saveToCache(CONTEXTS, contexts, FOLDER) + } + } + + fun saveCurrentPos(context: Context, position: Long) { + context.saveToCache(POSITION, position, FOLDER) + } + + fun Context.recoverTracks(withClear: Boolean = false): List, EchoMediaItem?>>? { + if (withClear && getFromCache(CLEARED) != false) return null + val tracks = getFromCache>(TRACKS, FOLDER) + val extensionIds = getFromCache>(EXTENSIONS, FOLDER) + val contexts = getFromCache>(CONTEXTS, FOLDER) + return tracks?.mapIndexedNotNull { index, track -> + val extensionId = extensionIds?.getOrNull(index) ?: return@mapIndexedNotNull null + val item = contexts?.getOrNull(index) + MediaState.Unloaded(extensionId, track) to item + } + } + + private fun Context.recoverQueue( + app: App, + downloads: List, + withClear: Boolean = false + ): List? { + val tracks = recoverTracks(withClear) ?: return null + return tracks.map { (state, item) -> + MediaItemUtils.build(app, downloads, state, item) + } + } + + fun Context.recoverIndex() = getFromCache(INDEX, FOLDER) + private fun Context.recoverPosition() = getFromCache(POSITION, FOLDER) + + + fun Context.recoverShuffle() = getFromCache(SHUFFLE, FOLDER) + fun saveShuffle(context: Context, shuffle: Boolean) { + context.saveToCache(SHUFFLE, shuffle, FOLDER) + } + + fun Context.recoverRepeat() = getFromCache(REPEAT, FOLDER) + fun saveRepeat(context: Context, repeat: Int) { + context.saveToCache(REPEAT, repeat, FOLDER) + } + + fun Context.recoverPlaylist( + app: App, + downloads: List, + withClear: Boolean = false + ): Triple, Int, Long> { + val items = recoverQueue(app, downloads, withClear) ?: emptyList() + val index = recoverIndex() ?: C.INDEX_UNSET + val position = recoverPosition() ?: -1L + return Triple(items, index, position) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ShufflePlayer.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ShufflePlayer.kt new file mode 100644 index 0000000..05aacf0 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/ShufflePlayer.kt @@ -0,0 +1,179 @@ +package dev.brahmkshatriya.echo.playback + +import androidx.annotation.OptIn +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ShuffleOrder + +@Suppress("unused") +@OptIn(UnstableApi::class) +class ShufflePlayer( + private val player: ExoPlayer, +) : ForwardingPlayer(player) { + + init { + player.setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) + } + + private fun getQueue() = (0 until mediaItemCount).map { player.getMediaItemAt(it) } + + private var isShuffled = false + private var original = getQueue() + + override fun setShuffleModeEnabled(enabled: Boolean) { + if (enabled) original = getQueue() + isShuffled = enabled + changeQueue(if (enabled) original.shuffled() else original) + player.shuffleModeEnabled = enabled + } + + override fun hasNextMediaItem(): Boolean { + return currentMediaItemIndex < mediaItemCount - 1 + } + + @Suppress("UNUSED_PARAMETER") + private fun log(name: String) { +// println(name) +// println("$isShuffled list ${original.size}: ${original.map { it.mediaMetadata.title }}") +// println("player ${mediaItemCount}: ${getQueue().map { it.mediaMetadata.title }}") + } + + private fun changeQueue(list: List) { + log("Change queue") + if (list.size <= 1) return + val currentMediaItem = list.first { it.mediaId == currentMediaItem?.mediaId } + val index = list.indexOf(currentMediaItem) + val before = list.take(index) - currentMediaItem + val after = list.takeLast(list.size - index) - currentMediaItem + if (currentMediaItemIndex > 0) player.removeMediaItems(0, currentMediaItemIndex) + player.addMediaItems(0, before) + player.removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) + player.addMediaItems(currentMediaItemIndex + 1, after) + } + + fun onMediaItemChanged(old: MediaItem, new: MediaItem) { + original = original.toMutableList().apply { + val index = indexOf(old).takeIf { it != -1 } ?: return + set(index, new) + } + log("Change media item") + } + + override fun addMediaItem(mediaItem: MediaItem) { + original = original + mediaItem + player.addMediaItem(mediaItem) + log("Add media item") + } + + override fun addMediaItems(mediaItems: MutableList) { + original = original + mediaItems + player.addMediaItems(mediaItems) + log("Add media items") + } + + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + original = original + mediaItem + player.addMediaItem(index, mediaItem) + log("Add media item at $index") + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + original = original + mediaItems + player.addMediaItems(index, mediaItems) + log("Add media items at $index") + } + + private fun getItemAt(index: Int) = player.getMediaItemAt(index).let { + original.first { item -> item.mediaId == it.mediaId } + } + + override fun removeMediaItem(index: Int) { + original = original - getItemAt(index) + player.removeMediaItem(index) + log("Remove media item at $index") + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + original = + original - (fromIndex until toIndex).map { getItemAt(it) }.toSet() + player.removeMediaItems(fromIndex, toIndex) + log("Remove media items from $fromIndex to $toIndex") + } + + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) { + original = original.toMutableList().apply { + val originalIndex = indexOf(getItemAt(index)).takeIf { it != -1 }!! + set(originalIndex, mediaItem) + } + player.replaceMediaItem(index, mediaItem) + log("Replace media item at $index") + } + + override fun replaceMediaItems( + fromIndex: Int, toIndex: Int, mediaItems: MutableList + ) { + original = original.toMutableList().apply { + val originalIndexes = (fromIndex until toIndex).map { i -> + indexOf(getItemAt(i)).takeIf { it != -1 }!! + } + originalIndexes.forEachIndexed { i, originalIndex -> + set(originalIndex, mediaItems[i]) + } + } + player.replaceMediaItems(fromIndex, toIndex, mediaItems) + log("Replace media items from $fromIndex to $toIndex") + } + + override fun setMediaItem(mediaItem: MediaItem) { + original = listOf(mediaItem) + player.setMediaItem(mediaItem) + log("Set media item") + } + + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) { + original = listOf(mediaItem) + player.setMediaItem(mediaItem, resetPosition) + log("Set media item") + } + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) { + original = listOf(mediaItem) + player.setMediaItem(mediaItem, startPositionMs) + log("Set media item") + } + + override fun setMediaItems(mediaItems: MutableList) { + original = mediaItems + player.setMediaItems(mediaItems) + log("Set media items") + } + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + original = mediaItems + player.setMediaItems(mediaItems, resetPosition) + log("Set media items") + } + + override fun setMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ) { + original = mediaItems + player.setMediaItems( + mediaItems, + startIndex.coerceAtMost(mediaItems.size - 1), + startPositionMs + ) + log("Set media items") + } + + override fun clearMediaItems() { + original = emptyList() + player.clearMediaItems() + log("Clear media items") + } + +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/exceptions/PlayerException.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/exceptions/PlayerException.kt new file mode 100644 index 0000000..b3d9ec5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/exceptions/PlayerException.kt @@ -0,0 +1,8 @@ +package dev.brahmkshatriya.echo.playback.exceptions + +import androidx.media3.common.MediaItem + +class PlayerException( + val mediaItem: MediaItem?, + override val cause: Throwable +) : Exception() \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/AudioFocusListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/AudioFocusListener.kt new file mode 100644 index 0000000..094bc23 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/AudioFocusListener.kt @@ -0,0 +1,79 @@ +package dev.brahmkshatriya.echo.playback.listener + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import androidx.media3.common.Player +import androidx.media3.common.Player.PlaybackSuppressionReason + +@Suppress("DEPRECATION") +class AudioFocusListener( + val context: Context, + val player: Player +) : Player.Listener { + private val handler = Handler(context.mainLooper) + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private lateinit var focusRequest: AudioFocusRequest + + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { + if (it == AudioManager.AUDIOFOCUS_GAIN) { + player.apply { + setAudioAttributes(audioAttributes, true) + seekTo(currentPosition) + } + abandonRequest() + } + } + + + private fun requestFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager.requestAudioFocus(focusRequest) + else audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) + } + + private fun abandonRequest() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + audioManager.abandonAudioFocusRequest(focusRequest) + else audioManager.abandonAudioFocus(audioFocusChangeListener) + } + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + setAcceptsDelayedFocusGain(true) + setOnAudioFocusChangeListener(audioFocusChangeListener, handler) + build() + } + } + + //TODO fix this to support player to play in calls + // if the audio suppression was not there from the start + // https://github.com/androidx/media/issues/1716 + onPlaybackSuppressionReasonChanged(player.playbackSuppressionReason) + } + + override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: @PlaybackSuppressionReason Int) { + if (playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) { + player.apply { + setAudioAttributes(audioAttributes, false) + player.playWhenReady = false + seekTo(currentPosition) + } + requestFocus() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/EffectsListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/EffectsListener.kt new file mode 100644 index 0000000..69c47c3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/EffectsListener.kt @@ -0,0 +1,156 @@ +package dev.brahmkshatriya.echo.playback.listener + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.media.audiofx.AudioEffect +import android.media.audiofx.Equalizer +import android.media.audiofx.LoudnessEnhancer +import androidx.annotation.OptIn +import androidx.core.content.edit +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.copyTo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.math.pow +import kotlin.math.roundToInt + +@OptIn(UnstableApi::class) +class EffectsListener( + private val exoPlayer: ExoPlayer, + private val context: Context, + private val audioSessionFlow: MutableStateFlow +) : Player.Listener { + + init { + audioSessionFlow.value = exoPlayer.audioSessionId + context.broadcastAudioSession() + } + + private val settings: SharedPreferences = context.globalFx() + private var oldSettings = settings + private fun applyCustomEffects() { + oldSettings.unregisterOnSharedPreferenceChangeListener(listener) + val current = context.getFxPrefs(settings, exoPlayer.currentMediaItem?.mediaId?.hashCode()) + ?: settings + oldSettings = current + current.registerOnSharedPreferenceChangeListener(listener) + applyPlayback(current) + effects.applySettings(current) + } + + private fun createEffects() = Effects(exoPlayer.audioSessionId) + + private fun applyPlayback(settings: SharedPreferences) { + val index = settings.getInt(PLAYBACK_SPEED, speedRange.indexOf(1f)) + val speed = speedRange.getOrNull(index) ?: 1f + val pitch = if (settings.getBoolean(CHANGE_PITCH, true)) speed else 1f + exoPlayer.playbackParameters = + PlaybackParameters(speed, pitch) + } + + private var effects: Effects = createEffects() + private val listener = OnSharedPreferenceChangeListener { _, _ -> applyCustomEffects() } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = applyCustomEffects() + override fun onAudioSessionIdChanged(audioSessionId: Int) { + release() + context.broadcastAudioSession() + audioSessionFlow.value = audioSessionId + effects = createEffects() + effects.applySettings(oldSettings) + } + + class Effects(sessionId: Int) { + private val equalizer = runCatching { Equalizer(0, sessionId) }.getOrNull() + private val gain = runCatching { LoudnessEnhancer(sessionId) }.getOrNull() + private fun applyBassBoost(strength: Int) = runCatching { + if (strength == 0) { + equalizer?.setEnabled(false) + gain?.setEnabled(false) + return@runCatching + } + gain?.setEnabled(true) + equalizer?.setEnabled(true) + equalizer?.apply { + val value = + (strength * bandLevelRange.last().toDouble() / 10).roundToInt().toShort() + val zero = numberOfBands.toDouble() * 2 / 3 + for (it in 0 until numberOfBands) { + val v = (-(it - zero).pow(3) * value / zero.pow(3)).roundToInt() + setBandLevel(it.toShort(), v.toShort()) + } + } + val g = (strength.toDouble().pow(1.toDouble() / 3) * 1600).roundToInt() + gain?.setTargetGain(g) + } + + fun release() { + runCatching { + equalizer?.release() + gain?.release() + } + } + + fun applySettings(settings: SharedPreferences) { + applyBassBoost(settings.getInt(BASS_BOOST, 0)) + } + } + + companion object { + const val GLOBAL_FX = "global_fx" + const val BASS_BOOST = "bass_boost" + const val PLAYBACK_SPEED = "playback_speed" + val speedRange = listOf( + 0.1f, 0.175f, 0.25f, 0.33f, 0.5f, 0.66f, 0.75f, 0.85f, 0.9f, 0.95f, + 1f, 1.05f, 1.1f, 1.15f, 1.25f, 1.33f, 1.5f, 1.66f, 1.75f, 1.88f, 2f, + 2.33f, 2.5f, 3f, 4f, 8f, 16f, 32f, 64f + ) + + const val CHANGE_PITCH = "change_pitch" + const val CUSTOM_EFFECTS = "custom_effects" + + fun Context.globalFx() = getSharedPreferences(GLOBAL_FX, Context.MODE_PRIVATE)!! + fun Context.deleteGlobalFx() = deleteSharedPreferences(GLOBAL_FX) + fun Context.getFxPrefs(settings: SharedPreferences, id: Int? = null): SharedPreferences? { + if (id == null) return null + val string = id.toString() + val hasCustom = settings.getStringSet(CUSTOM_EFFECTS, emptySet())?.contains(string) + ?: false + return if (!hasCustom) null + else getSharedPreferences("fx_$string", Context.MODE_PRIVATE)!!.apply { + if (getBoolean("init", false)) return@apply + settings.copyTo(this) + edit { putBoolean("init", true) } + } + } + + fun Context.deleteFxPrefs(id: Int) = + deleteSharedPreferences("fx_$id") + } + + private fun Context.broadcastAudioSession() { + val id = exoPlayer.audioSessionId + sendBroadcast(Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, id) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + }) + } + + private fun Context.broadcastAudioSessionClose(id: Int) { + sendBroadcast(Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, id) + }) + } + + private fun release() { + effects.release() + context.broadcastAudioSessionClose(audioSessionFlow.value) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/MediaSessionServiceListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/MediaSessionServiceListener.kt new file mode 100644 index 0000000..268e362 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/MediaSessionServiceListener.kt @@ -0,0 +1,63 @@ +package dev.brahmkshatriya.echo.playback.listener + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSessionService.Listener +import dev.brahmkshatriya.echo.R + +@UnstableApi +class MediaSessionServiceListener( + private val context: Context, + private val intent: PendingIntent +) : Listener { + + override fun onForegroundServiceStartNotAllowedException() { + if ( + Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) return + + val notificationManagerCompat = NotificationManagerCompat.from(context) + context.ensureNotificationChannel(notificationManagerCompat) + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_mono) + .setContentTitle(context.getString(R.string.app_name)) + .setStyle( + NotificationCompat.BigTextStyle().bigText(context.getString(R.string.app_name)) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(intent) + notificationManagerCompat.notify(NOTIFICATION_ID, builder.build()) + } + + private fun Context.ensureNotificationChannel( + notificationManagerCompat: NotificationManagerCompat + ) { + if ( + Build.VERSION.SDK_INT < 26 || + notificationManagerCompat.getNotificationChannel(CHANNEL_ID) != null + ) return + + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_DEFAULT, + ) + notificationManagerCompat.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "echo_notification_channel_id" + const val NOTIFICATION_ID = 1 + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerEventListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerEventListener.kt new file mode 100644 index 0000000..1911c05 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerEventListener.kt @@ -0,0 +1,134 @@ +package dev.brahmkshatriya.echo.playback.listener + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.session.MediaSession +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.retries +import dev.brahmkshatriya.echo.playback.PlayerCommands.getLikeButton +import dev.brahmkshatriya.echo.playback.PlayerCommands.getRepeatButton +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.ResumptionUtils +import dev.brahmkshatriya.echo.playback.exceptions.PlayerException +import dev.brahmkshatriya.echo.utils.Serializer.rootCause +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.reflect.KClass + +class PlayerEventListener( + private val context: Context, + private val scope: CoroutineScope, + private val session: MediaSession, + private val currentFlow: MutableStateFlow, + private val extensions: ExtensionLoader, + private val throwableFlow: MutableSharedFlow +) : Player.Listener { + + private val player get() = session.player + + private fun updateCustomLayout() = scope.launch(Dispatchers.Main) { + val item = player.currentMediaItem ?: return@launch + val supportsLike = withContext(Dispatchers.IO) { + extensions.music.getExtension(item.extensionId)?.isClient() ?: false + } + val commandButtons = listOfNotNull( + getRepeatButton(context, player.repeatMode), + getLikeButton(context, item).takeIf { supportsLike } + ) + session.setCustomLayout(commandButtons) + } + + private fun updateCurrentFlow() { + if (player.currentMediaItem == null && player.mediaItemCount > 0) + throw Exception("This is possible") + currentFlow.value = player.currentMediaItem?.let { + val isPlaying = player.isPlaying && player.playbackState == Player.STATE_READY + PlayerState.Current(player.currentMediaItemIndex, it, it.isLoaded, isPlaying) + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + updateCurrentFlow() + updateCustomLayout() + ResumptionUtils.saveIndex(context, player.currentMediaItemIndex) + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + updateCurrentFlow() + updateCustomLayout() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateCurrentFlow() + scope.launch { ResumptionUtils.saveQueue(context, player) } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + updateCustomLayout() + ResumptionUtils.saveRepeat(context, repeatMode) + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + ResumptionUtils.saveShuffle(context, shuffleModeEnabled) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + updateCurrentFlow() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updateCurrentFlow() + ResumptionUtils.saveCurrentPos(context, player.currentPosition) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int + ) { + ResumptionUtils.saveCurrentPos(context, player.currentPosition) + } + + private val maxRetries = 3 + private val maxSingleItemRetries = 1 + private var currentRetries = 0 + private var last: KClass<*>? = null + override fun onPlayerError(error: PlaybackException) { + val cause = error.cause ?: error + val mediaItem = player.currentMediaItem + scope.launch { throwableFlow.emit(PlayerException(mediaItem, cause)) } + + val old = last + last = cause.rootCause::class + if (old != null && old == last) currentRetries++ + else currentRetries = 0 + + if (mediaItem == null) return + val index = player.currentMediaItemIndex + val retries = mediaItem.retries + + if (currentRetries >= maxRetries) return + if (retries >= maxSingleItemRetries) { + val hasMore = index < player.mediaItemCount - 1 + if (!hasMore) return + player.seekToNextMediaItem() + } else { + val newItem = MediaItemUtils.withRetry(mediaItem) + player.replaceMediaItem(index, newItem) + } + player.prepare() + player.play() + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerRadio.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerRadio.kt new file mode 100644 index 0000000..81b08bf --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/PlayerRadio.kt @@ -0,0 +1,137 @@ +package dev.brahmkshatriya.echo.playback.listener + +import android.content.SharedPreferences +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Timeline +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed.Companion.pagedDataOfFirst +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.get +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.PlayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PlayerRadio( + private val app: App, + private val scope: CoroutineScope, + private val player: Player, + private val throwFlow: MutableSharedFlow, + private val stateFlow: MutableStateFlow, + private val extensionList: StateFlow>, + private val downloadFlow: StateFlow> +) : Player.Listener { + + companion object { + const val AUTO_START_RADIO = "auto_start_radio" + suspend fun start( + throwableFlow: MutableSharedFlow, + extension: Extension<*>, + item: EchoMediaItem, + itemContext: EchoMediaItem? + ): PlayerState.Radio.Loaded? { + if (!item.isRadioSupported) return null + return extension.getIf { + val radio = radio(item, itemContext) + val tracks = loadTracks(radio).pagedDataOfFirst() + PlayerState.Radio.Loaded(extension.id, radio, null) { + extension.get { tracks.loadPage(it) }.getOrThrow(throwableFlow) + } + }.getOrThrow(throwableFlow) + } + + suspend fun play( + player: Player, + downloadFlow: StateFlow>, + app: App, + stateFlow: MutableStateFlow, + loaded: PlayerState.Radio.Loaded + ) { + stateFlow.value = PlayerState.Radio.Loading + val tracks = loaded.tracks(loaded.cont) ?: return + + stateFlow.value = if (tracks.continuation == null) PlayerState.Radio.Empty + else loaded.copy(cont = tracks.continuation) + + val item = tracks.data.map { + MediaItemUtils.build( + app, + downloadFlow.value, + MediaState.Unloaded(loaded.clientId, it), + loaded.context + ) + } + + withContext(Dispatchers.Main) { + player.addMediaItems(item) + player.prepare() + } + } + } + + private suspend fun loadPlaylist() { + val mediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: return + val extensionId = mediaItem.extensionId + val item = mediaItem.track + val itemContext = mediaItem.context + stateFlow.value = PlayerState.Radio.Loading + val extension = extensionList.getExtension(extensionId) ?: return + val loaded = start(throwFlow, extension, item, itemContext) + stateFlow.value = loaded ?: PlayerState.Radio.Empty + if (loaded != null) play(player, downloadFlow, app, stateFlow, loaded) + } + + private var autoStartRadio = app.settings.getBoolean(AUTO_START_RADIO, true) + + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { pref, key -> + if (key != AUTO_START_RADIO) return@OnSharedPreferenceChangeListener + autoStartRadio = pref.getBoolean(AUTO_START_RADIO, true) + } + + init { + app.settings.registerOnSharedPreferenceChangeListener(listener) + } + + private suspend fun startRadio() { + if (!autoStartRadio) return + val shouldNotStart = withContext(Dispatchers.Main) { + player.run { + currentMediaItem == null || repeatMode != REPEAT_MODE_OFF || hasNextMediaItem() + } + } + if (shouldNotStart) return + when (val state = stateFlow.value) { + is PlayerState.Radio.Loading -> {} + is PlayerState.Radio.Empty -> loadPlaylist() + is PlayerState.Radio.Loaded -> play(player, downloadFlow, app, stateFlow, state) + } + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + scope.launch { startRadio() } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (player.mediaItemCount == 0) stateFlow.value = PlayerState.Radio.Empty + scope.launch { startRadio() } + } +} + diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/TrackingListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/TrackingListener.kt new file mode 100644 index 0000000..810ba24 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/listener/TrackingListener.kt @@ -0,0 +1,150 @@ +package dev.brahmkshatriya.echo.playback.listener + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.TrackerClient +import dev.brahmkshatriya.echo.common.clients.TrackerMarkClient +import dev.brahmkshatriya.echo.common.models.TrackDetails +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.runIf +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.utils.PauseTimer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +@UnstableApi +class TrackingListener( + private val player: Player, + private val scope: CoroutineScope, + extensions: ExtensionLoader, + private val currentFlow: MutableStateFlow, + private val throwableFlow: MutableSharedFlow +) : Player.Listener { + + private val musicList = extensions.music + private val trackerList = extensions.tracker + + private var current: MediaItem? = null + private var previousId: String? = null + + private suspend fun getDetails() = withContext(Dispatchers.Main) { + current?.let { curr -> + val (pos, total) = player.currentPosition to player.duration.takeIf { it != C.TIME_UNSET } + TrackDetails(curr.extensionId, curr.track, curr.context, pos, total) + } + } + + private fun trackMedia( + block: suspend TrackerClient.(extension: Extension<*>, details: TrackDetails?) -> Unit + ) { + scope.launch { + val details = getDetails() + val prevExtension = previousId?.takeIf { details?.extensionId != it } + ?.let { musicList.getExtension(it) } + val extension = musicList.getExtension(details?.extensionId) + val trackers = trackerList.value.filter { it.isEnabled } + prevExtension?.runIf(throwableFlow) { block(prevExtension, null) } + extension?.runIf(throwableFlow) { block(extension, details) } + trackers.forEach { + launch { it.runIf(throwableFlow) { block(it, details) } } + } + } + } + + private val mutex = Mutex() + private val timers = mutableMapOf() + private fun onTrackChanged(mediaItem: MediaItem?) { + previousId = current?.extensionId + current = mediaItem + scope.launch { + mutex.withLock { + timers.forEach { (_, timer) -> timer.pause() } + timers.clear() + } + trackMedia { extension, details -> + onTrackChanged(details) + details ?: return@trackMedia + val duration = (this as? TrackerMarkClient)?.getMarkAsPlayedDuration(details) + ?: return@trackMedia + mutex.withLock { + timers[extension.id] = PauseTimer(scope, duration) { + scope.launch { + extension.runIf(throwableFlow) { + onMarkAsPlayed(details) + } + } + } + } + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + scope.launch { + playState.value = getDetails() to isPlaying + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int + ) { + scope.launch { + val isPlaying = withContext(Dispatchers.Main) { player.isPlaying } + playState.value = getDetails() to isPlaying + } + if (reason == 2 || current == null) return + val mediaItem = newPosition.mediaItem ?: return + if (oldPosition.mediaItem != mediaItem) return + if (newPosition.positionMs != 0L) return + + onTrackChanged(current) + } + + override fun onPlayerError(error: PlaybackException) { + onTrackChanged(null) + } + + private val playState = MutableStateFlow>(null to false) + + init { + scope.launch { + currentFlow.map { it?.let { curr -> curr.mediaItem.takeIf { curr.isLoaded } } } + .distinctUntilChanged().collectLatest { + onTrackChanged(it) + } + } + scope.launch { + @OptIn(FlowPreview::class) + playState.debounce(500).collectLatest { (_, isPlaying) -> + mutex.withLock { + timers.forEach { (_, timer) -> + if (isPlaying) timer.resume() + else timer.pause() + } + } + trackMedia { _, details -> + onPlayingStateChanged(details, isPlaying) + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/PlayerBitmapLoader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/PlayerBitmapLoader.kt new file mode 100644 index 0000000..a51aaa8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/PlayerBitmapLoader.kt @@ -0,0 +1,34 @@ +package dev.brahmkshatriya.echo.playback.renderer + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.core.graphics.drawable.toBitmapOrNull +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.util.UnstableApi +import com.google.common.util.concurrent.ListenableFuture +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.utils.CoroutineUtils.futureCatching +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadDrawable +import kotlinx.coroutines.CoroutineScope + +@UnstableApi +class PlayerBitmapLoader( + val context: Context, + private val scope: CoroutineScope +) : BitmapLoader { + + override fun supportsMimeType(mimeType: String) = true + + override fun decodeBitmap(data: ByteArray) = scope.futureCatching { + BitmapFactory.decodeByteArray(data, 0, data.size) ?: error("Failed to decode bitmap") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = scope.futureCatching { + val cover = uri.getQueryParameter("actual_data")!!.toData().getOrThrow() + cover.loadDrawable(context)?.toBitmapOrNull() + ?: error("Failed to load bitmap of $cover") + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/RenderersFactory.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/RenderersFactory.kt new file mode 100644 index 0000000..0b8ab27 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/renderer/RenderersFactory.kt @@ -0,0 +1,38 @@ +package dev.brahmkshatriya.echo.playback.renderer + +import android.content.Context +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor + +@androidx.annotation.OptIn(UnstableApi::class) +class RenderersFactory( + context: Context +) : DefaultRenderersFactory(context) { + + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ) = run { + val silenceSkippingAudioProcessor = SilenceSkippingAudioProcessor( + 2_000_000, + (20_000 / 2_000_000).toFloat(), + 2_000_000, + 0, + 256, + ) + + DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), silenceSkippingAudioProcessor, SonicAudioProcessor() + ) + ) + .build() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/RawDataSource.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/RawDataSource.kt new file mode 100644 index 0000000..cf10a68 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/RawDataSource.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import dev.brahmkshatriya.echo.common.models.Streamable +import kotlinx.coroutines.runBlocking +import java.io.InputStream + +@OptIn(UnstableApi::class) +class RawDataSource : BaseDataSource(true) { + + class Factory : DataSource.Factory { + override fun createDataSource() = RawDataSource() + } + + private var stream: InputStream? = null + private var uri: Uri? = null + + override fun open(dataSpec: DataSpec): Long { + val streamable = dataSpec.customData as Streamable.Source.Raw + val (source, total) = runBlocking { + streamable.streamProvider!!.provide(dataSpec.position, dataSpec.length) + } + uri = dataSpec.uri + stream = source + return total + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return stream!!.read(buffer, offset, length) + } + + override fun getUri() = uri + + override fun close() { + stream?.close() + stream = null + uri = null + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableDataSource.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableDataSource.kt new file mode 100644 index 0000000..96c6613 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableDataSource.kt @@ -0,0 +1,77 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.content.Context +import androidx.core.net.toUri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.playback.source.StreamableResolver.Companion.copy + +@UnstableApi +class StreamableDataSource( + private val defaultDataSourceFactory: Lazy, + private val defaultHttpDataSourceFactory: Lazy, + private val rawDataSourceFactory: Lazy, +) : BaseDataSource(true) { + + class Factory( + context: Context, + ) : DataSource.Factory { + private val defaultDataSourceFactory = lazy { + DefaultDataSource.Factory(context, defaultHttpDataSourceFactory.value) + } + private val defaultHttpDataSourceFactory = lazy { + DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) + } + private val rawDataSourceFactory = lazy { RawDataSource.Factory() } + override fun createDataSource() = StreamableDataSource( + defaultDataSourceFactory, defaultHttpDataSourceFactory, rawDataSourceFactory + ) + } + + private var source: DataSource? = null + + override fun getUri() = source?.uri + + override fun read(buffer: ByteArray, offset: Int, length: Int) = + source?.read(buffer, offset, length) ?: throw Exception("Source not opened") + + override fun close() { + source?.close() + source = null + } + + override fun open(dataSpec: DataSpec): Long { + val result = dataSpec.customData as? Result<*> + val (factory, spec) = when (result) { + null -> defaultDataSourceFactory to dataSpec + else -> when (val streamable = result.getOrThrow() as Streamable.Source) { + is Streamable.Source.Raw -> rawDataSourceFactory to + dataSpec.copy(uri = streamable.uri, customData = streamable) + + is Streamable.Source.Http -> { + val spec = streamable.request.run { + defaultHttpDataSourceFactory.value.setDefaultRequestProperties(headers) + dataSpec.copy(uri = url.toUri(), httpRequestHeaders = headers) + } + defaultDataSourceFactory to spec + } + } + } + val source = factory.value.createDataSource() + this.source = source + return source.open(spec) + } + + companion object { + val Streamable.Source.uri + get() = when (this) { + is Streamable.Source.Http -> request.url.toUri() + is Streamable.Source.Raw -> "raw://${id.hashCode()}".toUri() + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableLoader.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableLoader.kt new file mode 100644 index 0000000..184bd06 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableLoader.kt @@ -0,0 +1,119 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.net.Uri +import androidx.media3.common.MediaItem +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Streamable.Source.Companion.toSource +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadStreamableMedia +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.MediaItemUtils.backgroundIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.downloaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.state +import dev.brahmkshatriya.echo.playback.MediaItemUtils.subtitleIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.media.MediaHeaderAdapter.Companion.playableString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.io.File + +class StreamableLoader( + private val app: App, + private val extensionListFlow: StateFlow>, + private val downloadFlow: StateFlow> +) { + suspend fun load(mediaItem: MediaItem) = withContext(Dispatchers.IO) { + extensionListFlow.first { it.isNotEmpty() } + val new = if (mediaItem.isLoaded) mediaItem + else MediaItemUtils.buildLoaded( + app, downloadFlow.value, mediaItem, loadTrack(mediaItem) + ) + + val server = async { loadServer(new) } + val background = + async { if (new.backgroundIndex < 0) null else loadBackground(new).getOrNull() } + val subtitle = async { if (new.subtitleIndex < 0) null else loadSubtitle(new).getOrNull() } + + MediaItemUtils.buildWithBackgroundAndSubtitle( + new, background.await(), subtitle.await() + ) to server.await() + } + + private suspend fun withClient( + mediaItem: MediaItem, + block: suspend (Extension<*>) -> Result + ): Result { + val extension = extensionListFlow.getExtensionOrThrow(mediaItem.extensionId) + return block(extension) + } + + private suspend fun loadTrack(item: MediaItem): MediaState.Loaded { + val track = withClient(item) { + Cached.loadMedia(app, it, item.state) + } + return track.getOrThrow() + } + + private suspend fun loadServer(mediaItem: MediaItem): Result { + val downloaded = mediaItem.downloaded + val servers = mediaItem.track.servers + val index = mediaItem.serverIndex + if (!downloaded.isNullOrEmpty() && servers.size == index) { + return runCatching { + Streamable.Media.Server( + downloaded.map { Uri.fromFile(File(it)).toString().toSource() }, + true + ) + } + } + return withClient(mediaItem) { + runCatching { + val isPlayable = mediaItem.track.playableString(app.context) + if (isPlayable != null) throw Exception(isPlayable) + val streamable = servers.getOrNull(index) ?: throw Exception("Server not found") + loadStreamableMedia( + app, it, mediaItem.track, streamable + ).getOrThrow() as Streamable.Media.Server + } + } + } + + private suspend fun loadBackground(mediaItem: MediaItem): Result { + val streams = mediaItem.track.backgrounds + val index = mediaItem.backgroundIndex + val streamable = streams[index] + return withClient(mediaItem) { + runCatching { + loadStreamableMedia( + app, it, mediaItem.track, streamable + ).getOrThrow() as Streamable.Media.Background + } + } + } + + private suspend fun loadSubtitle(mediaItem: MediaItem): Result { + val streams = mediaItem.track.subtitles + val index = mediaItem.subtitleIndex + val streamable = streams[index] + return withClient(mediaItem) { + runCatching { + loadStreamableMedia( + app, it, mediaItem.track, streamable + ).getOrThrow() as Streamable.Media.Subtitle + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableMediaSource.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableMediaSource.kt new file mode 100644 index 0000000..290fc4c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableMediaSource.kt @@ -0,0 +1,223 @@ +package dev.brahmkshatriya.echo.playback.source + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.CompositeMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaPeriod +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.upstream.Allocator +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.MediaItemUtils.backgroundIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.retries +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.sourceIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.subtitleIndex +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.select +import dev.brahmkshatriya.echo.playback.PlayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.IOException + +@UnstableApi +class StreamableMediaSource( + private var mediaItem: MediaItem, + private val app: App, + private val scope: CoroutineScope, + private val state: PlayerState, + private val loader: StreamableLoader, + private val cacheFactories: Factories, + private val factories: Factories, + private val changeFlow: MutableSharedFlow>, +) : CompositeMediaSource() { + + private var error: Throwable? = null + override fun maybeThrowSourceInfoRefreshError() { + error?.let { throw IOException(it) } + super.maybeThrowSourceInfoRefreshError() + } + + fun getFactory(source: Streamable.Source) = if (source.isLive) factories else cacheFactories + + private lateinit var actualSource: MediaSource + override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { + super.prepareSourceInternal(mediaTransferListener) + val handler = Util.createHandlerForCurrentLooper() + scope.launch { + var (new, serv) = runCatching { loader.load(mediaItem) }.getOrElse { + error = it + return@launch + } + val server = serv.getOrNull() + state.servers[new.mediaId] = serv + state.serverChanged.emit(Unit) + val sources = server?.sources + actualSource = when (sources?.size) { + 0, null -> factories.create(new, -1, null) + 1 -> { + val source = sources.first() + getFactory(source).create(new, 0, source) + } + + else -> { + if (server.merged) MergingMediaSource( + *sources.mapIndexed { index, source -> + getFactory(source).create(new, index, source) + }.toTypedArray() + ) else { + val index = mediaItem.sourceIndex + val source = sources.getOrNull(index) + ?: sources.select(app, new.extensionId) { it.quality } + val newIndex = sources.indexOf(source) + new = MediaItemUtils.buildSource(new, newIndex) + getFactory(source).create(new, newIndex, source) + } + } + } + + changeFlow.emit(mediaItem to new) + mediaItem = new + + handler.post { + runCatching { + prepareChildSource(null, actualSource) + }.getOrElse { + it.printStackTrace() + } + } + } + } + + override fun onChildSourceInfoRefreshed( + childSourceId: Nothing?, mediaSource: MediaSource, newTimeline: Timeline, + ) = refreshSourceInfo(newTimeline) + + override fun getMediaItem() = mediaItem + + override fun createPeriod( + id: MediaSource.MediaPeriodId, allocator: Allocator, startPositionUs: Long, + ) = actualSource.createPeriod(id, allocator, startPositionUs) + + override fun releasePeriod(mediaPeriod: MediaPeriod) = + actualSource.releasePeriod(mediaPeriod) + + override fun canUpdateMediaItem(mediaItem: MediaItem) = run { + this.mediaItem.apply { + if (retries != mediaItem.retries) return@run false + if (serverIndex != mediaItem.serverIndex) return@run false + if (this.sourceIndex != mediaItem.sourceIndex) return@run false + if (backgroundIndex != mediaItem.backgroundIndex) return@run false + if (subtitleIndex != mediaItem.subtitleIndex) return@run false + } + if (::actualSource.isInitialized) actualSource.canUpdateMediaItem(mediaItem) + else false + } + + override fun updateMediaItem(mediaItem: MediaItem) { + this.mediaItem = mediaItem + actualSource.updateMediaItem(mediaItem) + } + + data class Factories( + val dash: Lazy, + val hls: Lazy, + val default: Lazy, + ) { + fun create(mediaItem: MediaItem, index: Int, source: Streamable.Source?): MediaSource { + val type = (source as? Streamable.Source.Http)?.type + val factory = when (type) { + Streamable.SourceType.DASH -> dash + Streamable.SourceType.HLS -> hls + Streamable.SourceType.Progressive, null -> default + } + val new = MediaItemUtils.buildForSource(mediaItem, index, source) + return factory.value.createMediaSource(new) + } + } + + class Factory( + private val app: App, + private val scope: CoroutineScope, + private val state: PlayerState, + extensions: ExtensionLoader, + cache: SimpleCache, + downloadFlow: StateFlow>, + private val changeFlow: MutableSharedFlow>, + ) : MediaSource.Factory { + + private val loader = StreamableLoader(app, extensions.music, downloadFlow) + + val dataSourceFactory = StreamableDataSource.Factory(app.context) + val streamableResolver = StreamableResolver(app.context, state.servers) + + private val cacheDataSource = ResolvingDataSource.Factory( + CacheDataSource.Factory().setCache(cache) + .setUpstreamDataSourceFactory(dataSourceFactory), + streamableResolver + ) + + private val dataSource = ResolvingDataSource.Factory( + dataSourceFactory, streamableResolver + ) + + private val cacheFactories = createFactories(cacheDataSource) + + private val factories = createFactories(dataSource) + + private fun createFactories(dataSource: ResolvingDataSource.Factory) = Factories( + lazily { DashMediaSource.Factory(dataSource) }, + lazily { HlsMediaSource.Factory(dataSource) }, + lazily { DefaultMediaSourceFactory(dataSource) } + ) + + private var drmSessionManagerProvider: DrmSessionManagerProvider? = null + private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null + private fun lazily(factory: () -> MediaSource.Factory) = lazy { + factory().apply { + drmSessionManagerProvider?.let { setDrmSessionManagerProvider(it) } + loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } + } + } + + override fun getSupportedTypes() = intArrayOf( + C.CONTENT_TYPE_OTHER, C.CONTENT_TYPE_HLS, C.CONTENT_TYPE_DASH + ) + + override fun setDrmSessionManagerProvider( + drmSessionManagerProvider: DrmSessionManagerProvider, + ): MediaSource.Factory { + this.drmSessionManagerProvider = drmSessionManagerProvider + return this + } + + override fun setLoadErrorHandlingPolicy( + loadErrorHandlingPolicy: LoadErrorHandlingPolicy, + ): MediaSource.Factory { + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy + return this + } + + override fun createMediaSource(mediaItem: MediaItem) = StreamableMediaSource( + mediaItem, app, scope, state, loader, cacheFactories, factories, changeFlow + ) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableResolver.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableResolver.kt new file mode 100644 index 0000000..1bc3534 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/playback/source/StreamableResolver.kt @@ -0,0 +1,61 @@ +package dev.brahmkshatriya.echo.playback.source + +import android.content.Context +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.ResolvingDataSource.Resolver +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.playback.MediaItemUtils.toKey +import dev.brahmkshatriya.echo.playback.source.StreamableDataSource.Companion.uri +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import java.util.WeakHashMap + +class StreamableResolver( + private val context: Context, + private val current: WeakHashMap>, +) : Resolver { + + @OptIn(UnstableApi::class) + override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { + val (id, index) = dataSpec.uri.toString().toKey().getOrNull() ?: return dataSpec + val streamable = runCatching { current[id]!!.getOrThrow().sources[index] } + val uri = streamable.map { + if (!it.isLive) + context.saveToCache(it.uri.toString(), dataSpec.uri.toString(), "player") + it.uri + } + return dataSpec.copy(uri = uri.getOrNull(), customData = streamable) + } + + companion object { + + @OptIn(UnstableApi::class) + fun DataSpec.copy( + uri: Uri? = null, + uriPositionOffset: Long? = null, + httpMethod: Int? = null, + httpBody: ByteArray? = null, + httpRequestHeaders: Map? = null, + position: Long? = null, + length: Long? = null, + key: String? = null, + flags: Int? = null, + customData: Any? = null, + ): DataSpec { + return DataSpec.Builder() + .setUri(uri ?: this.uri) + .setUriPositionOffset(uriPositionOffset ?: this.uriPositionOffset) + .setHttpMethod(httpMethod ?: this.httpMethod) + .setHttpBody(httpBody ?: this.httpBody) + .setHttpRequestHeaders(httpRequestHeaders ?: this.httpRequestHeaders) + .setPosition(position ?: this.position) + .setLength(length ?: this.length) + .setKey(key ?: this.key) + .setFlags(flags ?: this.flags) + .setCustomData(customData ?: this.customData) + .build() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionFragment.kt new file mode 100644 index 0000000..9d6fe7f --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionFragment.kt @@ -0,0 +1,101 @@ +package dev.brahmkshatriya.echo.ui.common + +import android.app.Activity +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.FragmentExceptionBinding +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.getPasteLink +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyFabInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.utils.ContextUtils.copyToClipboard +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import kotlinx.coroutines.launch + +class ExceptionFragment : Fragment() { + companion object { + fun getBundle(data: ExceptionUtils.Data) = Bundle().apply { + putSerialized("data", data) + } + } + + private val data by lazy { + requireArguments().getSerialized("data")!!.getOrThrow() + } + + private var binding by autoCleared() + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentExceptionBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + applyInsets { + binding.nestedScrollView.applyContentInsets(it) + binding.fabContainer.applyFabInsets(it, systemInsets.value) + } + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.toolbarOutline.alpha = offset + binding.exceptionIconContainer.alpha = 1 - offset + } + + binding.exceptionMessage.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + binding.exceptionMessage.title = data.title + binding.exceptionDetails.text = data.trace + binding.fabCopy.setOnClickListener { + copyException() + } + + requireActivity().run { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return@run + registerScreenCaptureCallback(mainExecutor, screenCaptureCallback) + } + } + + private fun copyException() { + createSnack(R.string.copying_the_error) + lifecycleScope.launch { + val toCopy = getPasteLink(data).getOrElse { data.trace } + requireContext().copyToClipboard("Error", toCopy) + } + } + + private val screenCaptureCallback by lazy { + Activity.ScreenCaptureCallback { + copyException() + } + } + + override fun onDestroyView() { + super.onDestroyView() + requireActivity().run { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return@run + unregisterScreenCaptureCallback(screenCaptureCallback) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionUtils.kt new file mode 100644 index 0000000..8f5d678 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/ExceptionUtils.kt @@ -0,0 +1,208 @@ +package dev.brahmkshatriya.echo.ui.common + +import android.content.Context +import android.view.View +import androidx.fragment.app.FragmentActivity +import dev.brahmkshatriya.echo.MainActivity +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.download.exceptions.DownloadException +import dev.brahmkshatriya.echo.download.exceptions.DownloaderExtensionNotFoundException +import dev.brahmkshatriya.echo.download.tasks.BaseTask.Companion.getTitle +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity +import dev.brahmkshatriya.echo.extensions.exceptions.AppException +import dev.brahmkshatriya.echo.extensions.exceptions.ExtensionLoaderException +import dev.brahmkshatriya.echo.extensions.exceptions.ExtensionNotFoundException +import dev.brahmkshatriya.echo.extensions.exceptions.InvalidExtensionListException +import dev.brahmkshatriya.echo.extensions.exceptions.RequiredExtensionsMissingException +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.exceptions.PlayerException +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.extensions.login.LoginFragment +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListViewModel +import dev.brahmkshatriya.echo.utils.AppUpdater +import dev.brahmkshatriya.echo.utils.ContextUtils.appVersion +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer +import dev.brahmkshatriya.echo.utils.Serializer.rootCause +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException + +object ExceptionUtils { + + private fun Context.getTitle(throwable: Throwable): String? = when (throwable) { + is LinkageError, is ReflectiveOperationException -> getString(R.string.extension_out_of_date) + is UnknownHostException, is UnresolvedAddressException -> getString(R.string.no_internet) + + is ExtensionLoaderException -> + getString(R.string.error_loading_extension_from_x, throwable.clazz) + + is ExtensionNotFoundException -> getString(R.string.extension_x_not_found, throwable.id) + is RequiredExtensionsMissingException -> getString( + R.string.required_extensions_missing_x, + throwable.required.joinToString(", ") + ) + + is AppException -> when (throwable) { + is AppException.Unauthorized -> + getString(R.string.account_session_expired_in_x, throwable.extension.name) + + is AppException.LoginRequired -> + getString(R.string.x_login_required, throwable.extension.name) + + is AppException.NotSupported -> getString( + R.string.x_is_not_supported_in_x, + throwable.operation, + throwable.extension.name + ) + + is AppException.Other -> "${throwable.extension.name}: ${getFinalTitle(throwable.cause)}" + } + + is InvalidExtensionListException -> getString(R.string.invalid_extension_list) + is AppUpdater.UpdateException -> getString(R.string.error_updating_extension) + + is PlayerException -> "${throwable.mediaItem?.track?.title}: ${getFinalTitle(throwable.cause)}" + + is DownloadException -> { + val title = getTitle(throwable.type, throwable.downloadEntity.track.getOrNull()?.title ?: "???") + "${title}: ${getFinalTitle(throwable.cause)}" + } + + is DownloaderExtensionNotFoundException -> getString(R.string.no_download_extension) + + else -> null + } + + private fun getDetails(throwable: Throwable): String? = when (throwable) { + is ExtensionLoaderException -> """ + Class: ${throwable.clazz} + Source: ${throwable.source} + """.trimIndent() + + is ExtensionNotFoundException -> "Extension ID: ${throwable.id}" + is RequiredExtensionsMissingException -> + "Required Extension: ${throwable.required.joinToString(", ")}" + + is AppException -> """ + Type: ${throwable.extension.type} + ID: ${throwable.extension.id} + Extension: ${throwable.extension.name}(${throwable.extension.version}) + ${if (throwable is AppException.NotSupported) "Operation: ${throwable.operation}" else ""} + """.trimIndent() + + is InvalidExtensionListException -> "Link: ${throwable.link}" + + is PlayerException -> throwable.mediaItem?.let { + """ + Extension ID: ${it.extensionId} + Track: ${it.track.toJson()} + Stream: ${it.run { track.servers.getOrNull(serverIndex)?.toJson() }} + """.trimIndent() + } + + is DownloadException -> """ + Type: ${throwable.type} + Track: ${throwable.downloadEntity.toJson()} + """.trimIndent() + + is Serializer.DecodingException -> "JSON: ${throwable.json}" + + else -> null + } + + fun Context.getFinalTitle(throwable: Throwable): String? = + getTitle(throwable) ?: throwable.cause?.let { getFinalTitle(it) } ?: throwable.message + + + private fun getFinalDetails(throwable: Throwable): String = buildString { + getDetails(throwable)?.let { appendLine(it) } + throwable.cause?.let { append(getFinalDetails(it)) } + } + + private fun getStackTrace(throwable: Throwable): String = buildString { + appendLine("Version: ${appVersion()}") + appendLine(getFinalDetails(throwable)) + appendLine("---Stack Trace---") + appendLine(throwable.stackTraceToString()) + } + + @Serializable + data class Data(val title: String, val trace: String) + + fun Throwable.toData(context: Context) = run { + val title = context.getFinalTitle(this) ?: context.getString( + R.string.error_x, + message ?: this::class.run { simpleName ?: java.name } + ) + Data(title, getStackTrace(this)) + } + + + fun FragmentActivity.getMessage(throwable: Throwable, view: View?): Message { + val title = getFinalTitle(throwable) ?: getString( + R.string.error_x, + throwable.message ?: throwable::class.run { simpleName ?: java.name } + ) + val root = throwable.rootCause + return Message( + message = title, + when (root) { + is AppException.Unauthorized -> + Message.Action(getString(R.string.logout_and_login)) { + runCatching { openLoginException(root, view) } + } + + is AppException.LoginRequired -> Message.Action(getString(R.string.login)) { + runCatching { openLoginException(root, view) } + } + + else -> Message.Action(getString(R.string.view)) { + runCatching { openException(Data(title, getStackTrace(throwable)), view) } + } + } + ) + } + + private fun FragmentActivity.openException(data: Data, view: View? = null) { + openFragment(view, ExceptionFragment.getBundle(data)) + } + + fun FragmentActivity.openLoginException( + it: AppException.LoginRequired, view: View? = null + ) { + if (it is AppException.Unauthorized) { + val model by viewModel() + model.logout(UserEntity(it.extension.type, it.extension.id, it.userId, "")) + } + openFragment(view, LoginFragment.getBundle(it)) + } + + + fun MainActivity.setupExceptionHandler(handler: SnackBarHandler) { + observe(handler.app.throwFlow) { throwable -> + val message = getMessage(throwable, null) + handler.create(message) + } + } + + private val client = OkHttpClient() + suspend fun getPasteLink(data: Data) = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("https://paste.rs") + .post(data.trace.toRequestBody()) + .build() + runCatching { client.newCall(request).await().body.string() } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/FragmentUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/FragmentUtils.kt new file mode 100644 index 0000000..cfe5443 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/FragmentUtils.kt @@ -0,0 +1,156 @@ +package dev.brahmkshatriya.echo.ui.common + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.core.net.toFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN +import dev.brahmkshatriya.echo.MainActivity +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.download.DownloadFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.ui.extensions.WebViewUtils.onWebViewIntent +import dev.brahmkshatriya.echo.ui.media.MediaFragment +import org.koin.androidx.viewmodel.ext.android.viewModel + +object FragmentUtils { + inline fun Fragment.openFragment( + view: View? = null, bundle: Bundle? = null, + ) { + val viewModel by activityViewModels() + openFragment(id, parentFragmentManager, viewModel, view, bundle) + } + + inline fun openFragment( + cont: Int, + manager: FragmentManager, + viewModel: UiViewModel, + view: View? = null, + bundle: Bundle? = null, + ) { + viewModel.collapsePlayer() + manager.commit { + setReorderingAllowed(true) + addToBackStack(null) + val fragment = createFragment(bundle) + val old = manager.findFragmentById(cont) + if (old != null) hide(old) + add(cont, fragment) + setPrimaryNavigationFragment(fragment) + } + } + + inline fun createFragment( + bundle: Bundle? = null + ): T = T::class.java.getDeclaredConstructor().newInstance().apply { arguments = bundle } + + inline fun FragmentActivity.openFragment( + view: View? = null, bundle: Bundle? = null, cont: Int = R.id.navHostFragment + ) { + val oldFragment = supportFragmentManager.findFragmentById(cont) + if (oldFragment == null) { + val viewModel by viewModel() + openFragment(cont, supportFragmentManager, viewModel, view, bundle) + } else oldFragment.openFragment(view, bundle) + } + + inline fun Fragment.addIfNull( + id: Int, tag: String, args: Bundle? = null + ) { + childFragmentManager.run { + if (findFragmentByTag(tag) == null) commit { + val fragment = createFragment(args) + add(id, fragment, tag) + } + } + } + + fun MainActivity.setupIntents( + uiViewModel: UiViewModel, + ) { + addOnNewIntentListener { onIntent(uiViewModel, it) } + onIntent(uiViewModel, intent) + } + + private fun FragmentActivity.onIntent(uiViewModel: UiViewModel, intent: Intent?) { + this.intent = null + intent ?: return + val fromNotif = intent.hasExtra("fromNotification") + if (fromNotif) uiViewModel.run { + if (playerSheetState.value == STATE_HIDDEN) return@run + changePlayerState(STATE_EXPANDED) + changeMoreState(STATE_COLLAPSED) + return + } + val fromDownload = intent.hasExtra("fromDownload") + if (fromDownload) { + uiViewModel.selectedSettingsTab.value = 0 + openFragment() + return + } + val webViewRequest = intent.hasExtra("webViewRequest") + if (webViewRequest) { + onWebViewIntent(intent) + return + } + val uri = intent.data + when (uri?.scheme) { + "echo" -> runCatching { openItemFragmentFromUri(uri) } + "file" -> { + val viewModel by viewModel() + viewModel.installWithPrompt(listOf(uri.toFile())) + } + } + } + + private fun FragmentActivity.openItemFragmentFromUri(uri: Uri) { + when (val extensionType = uri.host) { + "music" -> { + val extensionId = uri.pathSegments.firstOrNull() + if (extensionId == null) { + createSnack("No extension id found") + return + } + val type = uri.pathSegments.getOrNull(1) + val id = uri.pathSegments.getOrNull(2) + if (id == null) { + val vm by viewModel() + vm.changeExtension(extensionId) + return + } + val name = uri.getQueryParameter("name").orEmpty() + val item: EchoMediaItem? = when (type) { + "artist" -> Artist(id, name) + "track" -> Track(id, name) + "album" -> Album(id, name) + "playlist" -> Playlist(id, name, false) + else -> null + } + if (item == null) { + createSnack("Invalid item type") + return + } + openFragment(null, MediaFragment.getBundle(extensionId, item, false)) + } + + else -> { + createSnack("Opening $extensionType extension is not possible") + } + } + } +} + diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/GridAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/GridAdapter.kt new file mode 100644 index 0000000..4913c01 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/GridAdapter.kt @@ -0,0 +1,56 @@ +package dev.brahmkshatriya.echo.ui.common + +import androidx.core.util.toKotlinPair +import androidx.core.view.doOnLayout +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.resolveStyledDimension +import kotlin.math.floor + +interface GridAdapter { + val adapter: RecyclerView.Adapter<*> + fun getSpanSize(position: Int, width: Int, count: Int): Int + + class Concat( + vararg adapters: GridAdapter + ) : GridAdapter { + override val adapter = ConcatAdapter(adapters.map { it.adapter }) + private val getSpanSizeMap = adapters.mapIndexed { index, gridAdapter -> + gridAdapter.adapter to gridAdapter::getSpanSize + }.toMap() + + override fun getSpanSize(position: Int, width: Int, count: Int): Int { + val (adapter, pos) = adapter.getWrappedAdapterAndPosition(position).toKotlinPair() + val getSpanSize = getSpanSizeMap[adapter] + ?: throw IllegalStateException("No span size function found for adapter: ${adapter.javaClass.name}") + return getSpanSize(pos, width, count) + } + } + + companion object { + fun configureGridLayout( + recycler: RecyclerView, gridAdapter: GridAdapter, even: Boolean = true + ) { + val context = recycler.context + val layoutManager = GridLayoutManager(context, 1) + recycler.doOnLayout { + val itemWidth = context.resolveStyledDimension(R.attr.itemCoverSize) + val width = it.width - it.paddingLeft - it.paddingRight + val calc = floor(width.toFloat() / (itemWidth + 8.dpToPx(context))).toInt() + val count = if (calc > 1) calc - if (even) calc % 2 else 0 else 1 + layoutManager.spanCount = count + layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val size = gridAdapter.getSpanSize(position, width, count) + return size + } + } + } + recycler.adapter = gridAdapter.adapter + recycler.layoutManager = layoutManager + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/PagedSource.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/PagedSource.kt new file mode 100644 index 0000000..c17a55e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/PagedSource.kt @@ -0,0 +1,63 @@ +package dev.brahmkshatriya.echo.ui.common + +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState +import dev.brahmkshatriya.echo.common.helpers.PagedData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +class PagedSource( + private val loaded: Result>?, + private val cached: Result>? = null +) : PagingSource() { + + val flow = Pager( + PagingConfig( + pageSize = 10, + enablePlaceholders = false, + prefetchDistance = 20 + ) + ) { this }.flow.flowOn(Dispatchers.IO) + + override val keyReuseSupported = true + override fun getRefreshKey(state: PagingState): String? { + val pos = state.anchorPosition ?: return null + val key = state.closestPageToPosition(pos)?.nextKey + invalidate(key) + return key + } + + private fun invalidate(key: String?) { + cached?.getOrNull()?.invalidate(key) + loaded?.getOrNull()?.invalidate(key) + } + + override suspend fun load(params: LoadParams): LoadResult { + val key = params.key + return runCatching { + val page = loaded?.getOrThrow()?.loadPage(key) ?: throw LoadingException() + LoadResult.Page(page.data, key, page.continuation) + }.getOrElse { error -> + val cachedPage = cached?.mapCatching { it.loadPage(key) }?.getOrNull() + return if (cachedPage == null || cachedPage.data.isEmpty()) LoadResult.Error(error) + else LoadResult.Page(cachedPage.data, key, cachedPage.continuation) + } + } + + companion object { + fun empty() = PagingData.Companion.empty( + LoadStates( + LoadState.Loading, + LoadState.NotLoading(false), + LoadState.NotLoading(true) + ) + ) + } + + class LoadingException() : Exception("Loading PagedSource") +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/SnackBarHandler.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/SnackBarHandler.kt new file mode 100644 index 0000000..2abc6af --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/SnackBarHandler.kt @@ -0,0 +1,101 @@ +package dev.brahmkshatriya.echo.ui.common + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import dev.brahmkshatriya.echo.MainActivity +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import java.util.WeakHashMap + +class SnackBarHandler( + val app: App, +) { + + private val messageFlow = app.messageFlow + private val messages = mutableListOf() + + suspend fun create(message: Message) { + if (messages.isEmpty()) messageFlow.emit(message) + if (!messages.contains(message)) messages.add(message) + } + + suspend fun remove(message: Message, dismissed: Boolean) { + if (dismissed) messages.remove(message) + if (messages.isNotEmpty()) messageFlow.emit(messages.first()) + } + + companion object { + fun MainActivity.setupSnackBar( + uiViewModel: UiViewModel, root: View + ): SnackBarHandler { + val handler by inject() + val padding = 8.dpToPx(this@setupSnackBar) + @Suppress("IDENTITY_SENSITIVE_OPERATIONS_WITH_VALUE_TYPE") + val snackBars = WeakHashMap() + fun updateInsets(snackBar: Snackbar) { + snackBar.view.updateLayoutParams { + val insets = uiViewModel.systemInsets.value + val snackbarInsets = uiViewModel.getSnackbarInsets() + marginStart = insets.start + snackbarInsets.start + padding + marginEnd = insets.end + snackbarInsets.end + padding + bottomMargin = snackbarInsets.bottom + padding + } + } + fun createSnackBar(message: Message) { + val snackBar = Snackbar.make(root, message.message, Snackbar.LENGTH_LONG) + snackBar.animationMode = Snackbar.ANIMATION_MODE_SLIDE + updateInsets(snackBar) + message.action?.run { snackBar.setAction(name) { handler() } } + snackBars[message.hashCode()] = snackBar + snackBar.addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + snackBars.remove(message.hashCode()) + lifecycleScope.launch { + handler.remove(message, event != DISMISS_EVENT_MANUAL) + } + } + }) + snackBar.show() + } + + observe(handler.messageFlow) { message -> + createSnackBar(message) + } + observe(uiViewModel.combined) { _ -> + snackBars.values.forEach { updateInsets(it) } + } + return handler + } + + fun Fragment.createSnack(message: Message) { + val handler by inject() + lifecycleScope.launch { handler.create(message) } + } + + fun Fragment.createSnack(message: String) { + createSnack(Message(message)) + } + + fun Fragment.createSnack(message: Int) { + createSnack(getString(message)) + } + + fun FragmentActivity.createSnack(message: Message) { + val handler by inject() + lifecycleScope.launch { handler.create(message) } + } + + fun FragmentActivity.createSnack(message: String) { + createSnack(Message(message)) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/UiViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/UiViewModel.kt new file mode 100644 index 0000000..1b26dba --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/common/UiViewModel.kt @@ -0,0 +1,483 @@ +package dev.brahmkshatriya.echo.ui.common + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.BackEventCompat +import androidx.activity.OnBackPressedCallback +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.forEach +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN +import com.google.android.material.color.MaterialColors +import com.google.android.material.navigation.NavigationBarView +import com.google.android.material.navigationrail.NavigationRailView +import dev.brahmkshatriya.echo.MainActivity +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.main.MainFragment +import dev.brahmkshatriya.echo.ui.player.PlayerColors +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import dev.brahmkshatriya.echo.utils.ContextUtils.emit +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadDrawable +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.animateTranslation +import dev.brahmkshatriya.echo.utils.ui.GradientDrawable +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isRTL +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.lang.ref.WeakReference +import kotlin.math.max +import kotlin.math.min + +class UiViewModel( + context: Context, + extensionLoader: ExtensionLoader, + private val playerState: PlayerState +) : ViewModel() { + + data class Insets( + val top: Int = 0, + val bottom: Int = 0, + val start: Int = 0, + val end: Int = 0 + ) { + fun add(vararg insets: Insets) = insets.fold(this) { acc, it -> + Insets( + acc.top + it.top, + acc.bottom + it.bottom, + acc.start + it.start, + acc.end + it.end + ) + } + } + + var currentAppColor: String? = null + val navigation = MutableStateFlow(context.getFromCache("main_nav") ?: 0).also { flow -> + viewModelScope.launch { flow.collect { context.saveToCache("main_nav", it) } } + } + val selectedSettingsTab = MutableStateFlow(0) + val navigationReselected = MutableSharedFlow() + val navIds = listOf( + R.id.homeFragment, + R.id.searchFragment, + R.id.libraryFragment + ) + + val currentNavBackground = MutableStateFlow(null) + private val extensionColor = extensionLoader.current.transform { extension -> + emit(null) + val drawable = extension?.metadata?.icon?.loadDrawable(context) ?: return@transform + emit(PlayerColors.getDominantColor(drawable.toBitmap()).toDrawable()) + } + + private val navViewInsets = MutableStateFlow(Insets()) + private val playerNavViewInsets = MutableStateFlow(Insets()) + private val playerInsets = MutableStateFlow(Insets()) + val systemInsets = MutableStateFlow(Insets()) + val isMainFragment = MutableStateFlow(true) + + val combined = systemInsets.combine(navViewInsets) { system, nav -> + if (isMainFragment.value) system.add(nav) else system + }.combine(playerInsets) { system, player -> + system.add(player) + }.stateIn(viewModelScope, Lazily, Insets()) + + fun getCombined() = (if (isMainFragment.value) systemInsets.value.add(navViewInsets.value) + else systemInsets.value).add(playerInsets.value) + + fun getSnackbarInsets(): Insets { + if (playerSheetState.value == STATE_EXPANDED) return Insets() + if (isMainFragment.value) return navViewInsets.value.add(playerInsets.value) + return playerInsets.value + } + + fun setPlayerNavViewInsets(context: Context, isNavVisible: Boolean, isRail: Boolean): Insets { + val insets = context.resources.run { + if (!isNavVisible) return@run Insets() + val height = getDimensionPixelSize(R.dimen.nav_height) + if (!isRail) return@run Insets(bottom = height) + val width = getDimensionPixelSize(R.dimen.nav_width) + if (context.isRTL()) Insets(end = width) else Insets(start = width) + } + playerNavViewInsets.value = insets + return insets + } + + fun setNavInsets(insets: Insets) { + navViewInsets.value = insets + } + + fun setSystemInsets(context: Context, insets: WindowInsetsCompat) { + val system = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) + val display = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val inset = system.run { + val top = if (top > 0) top else display.top + val bottom = if (bottom > 0) bottom else display.bottom + val left = if (left > 0) left else display.left + val right = if (right > 0) right else display.right + if (context.isRTL()) Insets(top, bottom, right, left) + else Insets(top, bottom, left, right) + } + systemInsets.value = inset + } + + fun setPlayerInsets(context: Context, isVisible: Boolean) { + val insets = if (isVisible) { + val height = context.resources.getDimensionPixelSize(R.dimen.collapsed_cover_size) + Insets(bottom = height + 8.dpToPx(context)) + } else Insets() + playerInsets.value = insets + } + + val playerBgVisible = MutableStateFlow(false) + private fun getState() = + if (playerState.current.value != null) STATE_COLLAPSED else STATE_HIDDEN + + val playerSheetState = MutableStateFlow(getState()) + val playerSheetOffset = MutableStateFlow(0f) + val moreSheetState = MutableStateFlow(STATE_COLLAPSED) + val moreSheetOffset = MutableStateFlow(0f) + val playerBackProgress = MutableStateFlow(0f) + private var playerBackPressCallback: OnBackPressedCallback? = null + private var moreBackPressCallback: OnBackPressedCallback? = null + fun backPressCallback() = object : OnBackPressedCallback(false) { + val backPress + get() = moreBackPressCallback ?: playerBackPressCallback + + override fun handleOnBackStarted(backEvent: BackEventCompat) { + backPress?.handleOnBackStarted(backEvent) + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + backPress?.handleOnBackProgressed(backEvent) + } + + override fun handleOnBackPressed() { + backPress?.handleOnBackPressed() + } + + override fun handleOnBackCancelled() { + backPress?.handleOnBackCancelled() + } + } + + fun collapsePlayer() { + changePlayerState(getState()) + changeMoreState(STATE_COLLAPSED) + } + + private var playerBehaviour = WeakReference>(null) + fun changePlayerState(state: Int) { + val behavior = playerBehaviour.get() ?: return + if (state == STATE_HIDDEN) behavior.isHideable = true + behavior.state = state + } + + private var moreBehaviour = WeakReference>(null) + fun changeMoreState(state: Int) { + val behavior = moreBehaviour.get() ?: return + behavior.state = state + } + + var lastMoreTab = R.id.queue + var playerControlsHeight = MutableStateFlow(0) + val playerDrawable = MutableStateFlow(null) + val playerColors = MutableStateFlow(null) + + val mainBgDrawable = playerDrawable.combine(extensionColor) { a, b -> a ?: b } + + fun changeBgVisible(show: Boolean) { + playerBgVisible.value = show + if (!show && moreSheetState.value == STATE_EXPANDED) + changeMoreState(STATE_COLLAPSED) + } + + companion object { + const val BACKGROUND_GRADIENT = "bg_gradient" + fun Fragment.applyGradient(view: View, drawable: Drawable?) { + val settings = requireContext().getSettings() + val isGradient = settings.getBoolean(BACKGROUND_GRADIENT, true) + val drawable = if (isGradient) { + drawable ?: MaterialColors.getColor(view, androidx.appcompat.R.attr.colorPrimary) + .toDrawable() + } else null + view.background = GradientDrawable.createBlurred(view, drawable) + } + + fun Fragment.applyInsets(vararg flows: Flow<*>, block: UiViewModel.(Insets) -> Unit) { + val uiViewModel by activityViewModel() + val flows = listOf(uiViewModel.combined) + flows + observe(flows.merge()) { uiViewModel.block(uiViewModel.combined.value) } + } + + fun Fragment.applyInsetsWithChild( + appBar: View, + child: View?, + bottom: Int = 12, + block: UiViewModel.(Insets) -> Unit = {} + ) { + val uiViewModel by activityViewModel() + observe(uiViewModel.combined) { insets -> + child?.updatePadding( + bottom = insets.bottom + bottom.dpToPx(child.context), + ) + appBar.updatePaddingRelative( + top = insets.top, + start = insets.start, + end = insets.end + ) + uiViewModel.block(insets) + } + } + + fun View.applyContentInsets( + insets: Insets, horizontal: Int = 0, vertical: Int = 0, bottom: Int = 0 + ) { + val horizontalPadding = horizontal.dpToPx(context) + val verticalPadding = vertical.dpToPx(context) + updatePaddingRelative( + top = verticalPadding, + bottom = insets.bottom + verticalPadding + bottom.dpToPx(context), + start = insets.start + horizontalPadding, + end = insets.end + horizontalPadding + ) + } + + fun View.applyInsets(it: Insets, vertical: Int, horizontal: Int, bottom: Int = 0) { + val verticalPadding = vertical.dpToPx(context) + val horizontalPadding = horizontal.dpToPx(context) + val bottomPadding = bottom.dpToPx(context) + updatePaddingRelative( + top = verticalPadding + it.top, + bottom = bottomPadding + verticalPadding + it.bottom, + start = horizontalPadding + it.start, + end = horizontalPadding + it.end, + ) + } + + fun View.applyInsets(it: Insets, paddingDp: Int = 0) { + val padding = paddingDp.dpToPx(context) + updatePaddingRelative( + top = it.top + padding, + bottom = it.bottom + padding, + start = it.start + padding, + end = it.end + padding, + ) + } + + fun View.applyHorizontalInsets(it: Insets, isLandScape: Boolean = false) { + updatePaddingRelative( + start = if (!isLandScape) it.start else 0, + end = it.end + ) + } + + fun View.applyFabInsets(it: Insets, system: Insets, paddingDp: Int = 0) { + val padding = paddingDp.dpToPx(context) + updatePaddingRelative( + bottom = it.bottom - system.bottom + padding, + start = it.start + padding, + end = it.end + padding, + ) + } + + fun Fragment.applyBackPressCallback(callback: ((Int) -> Unit)? = null) { + val activity = requireActivity() + val viewModel by activity.viewModel() + val backPress = viewModel.backPressCallback() + observe(viewModel.playerSheetState) { + backPress.isEnabled = it == STATE_EXPANDED + callback?.invoke(it) + } + activity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPress) + } + + private fun BottomSheetBehavior.backPressCallback( + onProgress: (Float) -> Unit = {}, + ) = object : OnBackPressedCallback(true) { + override fun handleOnBackStarted(backEvent: BackEventCompat) { + startBackProgress(backEvent) + onProgress(0f) + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + updateBackProgress(backEvent) + onProgress(min(1f, backEvent.progress * 2)) + } + + override fun handleOnBackPressed() { + handleBackInvoked() + onProgress(0f) + } + + override fun handleOnBackCancelled() { + cancelBackProgress() + onProgress(0f) + } + } + + const val NAVBAR_GRADIENT = "navbar_gradient" + fun MainActivity.setupNavBarAndInsets( + uiViewModel: UiViewModel, + root: View, + navView: NavigationBarView + ) { + val isRail = navView is NavigationRailView + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + uiViewModel.setSystemInsets(this, insets) + val navBarSize = uiViewModel.systemInsets.value.bottom + val full = getSettings().getBoolean(NAVBAR_GRADIENT, true) + GradientDrawable.applyNav(navView, isRail, navBarSize, !full) + insets + } + + navView.setOnItemSelectedListener { + uiViewModel.navigation.value = uiViewModel.navIds.indexOf(it.itemId) + true + } + navView.menu.forEach { + findViewById(it.itemId).setOnClickListener { _ -> + uiViewModel.run { + if (navigation.value == navIds.indexOf(it.itemId)) + emit(navigationReselected, navIds.indexOf(it.itemId)) + } + navView.selectedItemId = it.itemId + } + } + + fun animateNav(animate: Boolean) { + val isMainFragment = uiViewModel.isMainFragment.value + val insets = + uiViewModel.setPlayerNavViewInsets(this, isMainFragment, isRail) + val isPlayerCollapsed = uiViewModel.playerSheetState.value != STATE_EXPANDED + navView.animateTranslation(isRail, isMainFragment, isPlayerCollapsed, animate) { + uiViewModel.setNavInsets(insets) + if (isPlayerCollapsed) navView.updateLayoutParams { + bottomMargin = -it.toInt() + } + } + } + + animateNav(false) + supportFragmentManager.addOnBackStackChangedListener { + val current = supportFragmentManager.findFragmentById(R.id.navHostFragment) + val isMain = current is MainFragment + uiViewModel.isMainFragment.value = isMain + animateNav(true) + } + observe(uiViewModel.navigation) { navView.selectedItemId = uiViewModel.navIds[it] } + observe(uiViewModel.playerSheetOffset) { + if (!uiViewModel.isMainFragment.value) return@observe + val offset = max(0f, it) + if (isRail) navView.translationX = -navView.width * offset + else navView.translationY = navView.height * offset + navView.menu.forEach { item -> + findViewById(item.itemId).apply { + translationX = 0f + translationY = 0f + } + } + } + } + + fun isFinalState(state: Int): Boolean { + return state == STATE_HIDDEN || state == STATE_COLLAPSED || state == STATE_EXPANDED + } + + fun LifecycleOwner.setupPlayerBehavior(viewModel: UiViewModel, view: View) { + val behavior = BottomSheetBehavior.from(view) + viewModel.playerBehaviour = WeakReference(behavior) + observe(viewModel.moreSheetState) { behavior.isDraggable = it == STATE_COLLAPSED } + viewModel.playerBackPressCallback = behavior.backPressCallback { + viewModel.playerBackProgress.value = it + } + + val combined = + viewModel.run { playerNavViewInsets.combine(systemInsets) { nav, _ -> nav } } + observe(combined) { + val bottomPadding = 8.dpToPx(view.context) + val collapsedCoverSize = + view.resources.getDimensionPixelSize(R.dimen.collapsed_cover_size) + bottomPadding + val peekHeight = + view.resources.getDimensionPixelSize(R.dimen.bottom_player_peek_height) + val height = if (it.bottom == 0) collapsedCoverSize else peekHeight + val newHeight = viewModel.systemInsets.value.bottom + height + behavior.peekHeight = newHeight + if (viewModel.playerSheetState.value != STATE_HIDDEN) + animateTranslation(view, behavior.peekHeight, newHeight) + } + val callback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + val expanded = newState == STATE_EXPANDED + behavior.isHideable = !expanded + viewModel.playerSheetState.value = newState + if (!isFinalState(newState)) return + viewModel.setPlayerInsets(view.context, newState != STATE_HIDDEN) + onSlide(view, if (newState == STATE_EXPANDED) 1f else 0f) + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + viewModel.playerSheetOffset.value = slideOffset + } + } + val state = viewModel.playerSheetState.value + callback.onStateChanged(view, state) + callback.onSlide(view, if (state == STATE_EXPANDED) 1f else 0f) + behavior.addBottomSheetCallback(callback) + } + + fun setupPlayerMoreBehavior(viewModel: UiViewModel, view: View) { + val behavior = BottomSheetBehavior.from(view) + viewModel.moreBehaviour = WeakReference(behavior) + val backPress = behavior.backPressCallback() + val callback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + viewModel.moreSheetState.value = newState + viewModel.moreBackPressCallback = + backPress.takeIf { newState != STATE_COLLAPSED } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + val offset = max(0f, slideOffset) + viewModel.moreSheetOffset.value = offset + } + } + val state = viewModel.moreSheetState.value + callback.onStateChanged(view, state) + callback.onSlide(view, if (state == STATE_EXPANDED) 1f else 0f) + behavior.addBottomSheetCallback(callback) + } + + fun SwipeRefreshLayout.configure(it: Insets = Insets()) { + setProgressViewOffset(true, it.top, 72.dpToPx(context) + it.top) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadFragment.kt new file mode 100644 index 0000000..dee3f55 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadFragment.kt @@ -0,0 +1,100 @@ +package dev.brahmkshatriya.echo.ui.download + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.databinding.FragmentDownloadBinding +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.getFeed +import dev.brahmkshatriya.echo.ui.common.ExceptionFragment +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyFabInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.download.DownloadsAdapter.Companion.toItems +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.feed.FeedData +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.media.LineAdapter +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import org.koin.androidx.viewmodel.ext.android.viewModel + +class DownloadFragment : Fragment(R.layout.fragment_download) { + + private val vm by viewModel() + private val downloadsAdapter by lazy { + DownloadsAdapter(object : DownloadsAdapter.Listener { + override fun onCancel(trackId: Long) = vm.cancel(trackId) + override fun onRestart(trackId: Long) = vm.restart(trackId) + override fun onExceptionClicked(data: ExceptionUtils.Data) = requireActivity() + .openFragment(null, ExceptionFragment.getBundle(data)) + }) + } + + private val feedViewModel by viewModel() + private val feedData by lazy { + val flow = vm.downloader.unified.downloadFeed + feedViewModel.getFeedData( + "downloads", Feed.Buttons(), false, flow + ) { + val feed = requireContext().getFeed(flow.value) + FeedData.State(UnifiedExtension.metadata.id, null, feed) + } + } + + private val feedListener by lazy { getFeedListener() } + private val feedAdapter by lazy { + getFeedAdapter(feedData, feedListener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentDownloadBinding.bind(view) + setupTransition(view) + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.toolbarOutline.alpha = offset + binding.iconContainer.alpha = 1 - offset + } + binding.toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + applyInsets { + binding.recyclerView.applyContentInsets(it, 20, 8, 72) + binding.fabContainer.applyFabInsets(it, systemInsets.value) + } + FastScrollerHelper.applyTo(binding.recyclerView) + val lineAdapter = LineAdapter() + binding.fabCancel.setOnClickListener { + vm.cancelAll() + } + binding.recyclerView.itemAnimator = null + getTouchHelper(feedListener).attachToRecyclerView(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + GridAdapter.Concat( + downloadsAdapter, + lineAdapter, + feedAdapter.withLoading(this) + ) + ) + observe(vm.flow) { infos -> + binding.fabCancel.isVisible = infos.any { it.download.finalFile == null } + lineAdapter.loadState = if (infos.isNotEmpty()) LoadState.Loading + else LoadState.NotLoading(false) + downloadsAdapter.submitList(infos.toItems(vm.extensions.music.value)) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadViewModel.kt new file mode 100644 index 0000000..d1a7085 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadViewModel.kt @@ -0,0 +1,108 @@ +package dev.brahmkshatriya.echo.ui.download + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.DownloadClient +import dev.brahmkshatriya.echo.common.models.DownloadContext +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.extensions.add.ExtensionsAddBottomSheet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DownloadViewModel( + app: App, + extensionLoader: ExtensionLoader, + val downloader: Downloader, +) : ViewModel() { + + private val app = app.context + private val messageFlow = app.messageFlow + private val throwableFlow = app.throwFlow + + @OptIn(ExperimentalCoroutinesApi::class) + val downloadExtensions = extensionLoader.misc.mapLatest { list -> + list.filter { it.isEnabled && it.isClient() } + }.stateIn(viewModelScope, Eagerly, null) + + val extensions = extensionLoader + + val flow = downloader.flow + + fun addToDownload( + activity: FragmentActivity, + extensionId: String, + item: EchoMediaItem, + context: EchoMediaItem?, + ) = viewModelScope.launch(Dispatchers.IO) { + with(activity) { + messageFlow.emit(Message(getString(R.string.downloading_x, item.title))) + val downloadExt = downloadExtensions.first { it != null }?.firstOrNull() + ?: return@with messageFlow.emit( + Message( + app.getString(R.string.no_download_extension), + Message.Action(getString(R.string.add_extension)) { + ExtensionsAddBottomSheet().show(supportFragmentManager, null) + } + ) + ) + + val downloads = + downloadExt.getIf>(throwableFlow) { + getDownloadTracks(extensionId, item, context) + } ?: return@with + + if (downloads.isEmpty()) return@with messageFlow.emit( + Message(app.getString(R.string.nothing_to_download_in_x, item.title)) + ) + + downloader.add(downloads) + messageFlow.emit( + Message( + getString(R.string.download_started), + Message.Action(getString(R.string.view)) { + openFragment() + } + ) + ) + } + } + + fun cancel(trackId: Long) { + downloader.cancel(trackId) + } + + fun restart(trackId: Long) { + downloader.restart(trackId) + } + + fun cancelAll() { + downloader.cancelAll() + } + + fun deleteDownload(item: EchoMediaItem) { + when (item) { + is Track -> downloader.deleteDownload(item.id) + else -> downloader.deleteContext(item.id) + } + viewModelScope.launch { + messageFlow.emit( + Message(app.getString(R.string.removed_x_from_downloads, item.title)) + ) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadsAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadsAdapter.kt new file mode 100644 index 0000000..a834441 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/download/DownloadsAdapter.kt @@ -0,0 +1,186 @@ +package dev.brahmkshatriya.echo.ui.download + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.models.Progress +import dev.brahmkshatriya.echo.databinding.ItemDownloadBinding +import dev.brahmkshatriya.echo.databinding.ItemDownloadTaskBinding +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.download.db.models.ContextEntity +import dev.brahmkshatriya.echo.download.db.models.DownloadEntity +import dev.brahmkshatriya.echo.download.db.models.TaskType +import dev.brahmkshatriya.echo.download.tasks.BaseTask.Companion.getTitle +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class DownloadsAdapter( + private val listener: Listener, +) : ScrollAnimListAdapter( + object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + return when (oldItem) { + is Download -> if (newItem !is Download) false + else oldItem.downloadEntity.id == newItem.downloadEntity.id + + is Task -> if (newItem !is Task) false + else oldItem.id == newItem.id + } + } + } +), GridAdapter { + + interface Listener { + fun onExceptionClicked(data: ExceptionUtils.Data) + fun onCancel(trackId: Long) + fun onRestart(trackId: Long) + } + + sealed class ViewHolder(itemView: View) : ScrollAnimViewHolder(itemView) + + class DownloadViewHolder( + parent: ViewGroup, + private val listener: Listener, + private val binding: ItemDownloadBinding = ItemDownloadBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ViewHolder(binding.root) { + init { + binding.imageView.clipToOutline = true + } + + fun bind(item: Download) { + val entity = item.downloadEntity + binding.apply { + val track = entity.track.getOrNull() + title.text = track?.title + track?.cover.loadInto(imageView, R.drawable.art_music) + item.extension?.metadata?.icon?.loadAsCircle( + extensionIcon, R.drawable.ic_extension + ) { + extensionIcon.setImageDrawable(it) + } + val sub = item.context?.mediaItem?.getOrNull()?.title + subtitle.text = sub + subtitle.isVisible = !sub.isNullOrEmpty() + + exception.text = entity.exception?.title + exception.isVisible = exception.text.isNotEmpty() + + remove.setOnClickListener { + listener.onCancel(entity.id) + } + + retry.isVisible = entity.exception != null + retry.setOnClickListener { + listener.onRestart(entity.id) + } + root.setOnClickListener { + val data = entity.exception ?: return@setOnClickListener + listener.onExceptionClicked(data) + } + } + } + } + + class TaskViewHolder( + parent: ViewGroup, + private val binding: ItemDownloadTaskBinding = ItemDownloadTaskBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ViewHolder(binding.root) { + fun bind(item: Task) = binding.apply { + progressBar.isIndeterminate = item.progress.size == 0L + progressBar.max = item.progress.size.toInt() + progressBar.progress = item.progress.progress.toInt() + subtitle.text = item.progress.toText() + title.text = root.context.run { + getTitle(item.taskType, getString(R.string.download)) + } + } + } + + + sealed interface Item + data class Download( + val context: ContextEntity?, + val downloadEntity: DownloadEntity, + val extension: Extension<*>?, + ) : Item + + data class Task(val taskType: TaskType, val progress: Progress, val id: Long) : Item + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is Download -> 0 + is Task -> 1 + } + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = 2.coerceAtLeast(count) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + 0 -> DownloadViewHolder(parent, listener) + 1 -> TaskViewHolder(parent) + else -> throw IllegalArgumentException("Invalid view type") + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + when (holder) { + is DownloadViewHolder -> { + val item = getItem(position) as Download + holder.bind(item) + } + + is TaskViewHolder -> { + val item = getItem(position) as Task + holder.bind(item) + } + } + } + + + companion object { + fun List.toItems(extensions: List>) = filter { + it.download.finalFile == null + }.flatMap { info -> + val download = info.download + val extension = extensions.find { it.id == download.extensionId } + listOf(Download(info.context, download, extension)) + info.workers.map { + Task(it.first, it.second, download.id) + } + } + + private val SPEED_UNITS = arrayOf("", "KB", "MB", "GB") + fun Progress.toText() = buildString { + if (size > 0) append("%.2f%% • ".format(progress.toFloat() / size * 100)) + append( + if (size > 0) "${convertBytes(progress)} / ${convertBytes(size)}" + else convertBytes(progress) + ) + if (speed > 0) append(" • ${convertBytes(speed)}/s") + } + + private fun convertBytes(bytes: Long): String { + var value = bytes.toFloat() + var unitIndex = 0 + + while (value >= 500 && unitIndex < SPEED_UNITS.size - 1) { + value /= 1024 + unitIndex++ + } + return "%.2f %s".format(value, SPEED_UNITS[unitIndex]) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ApkLinkParser.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ApkLinkParser.kt new file mode 100644 index 0000000..a6de2da --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ApkLinkParser.kt @@ -0,0 +1,329 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.xml.sax.Attributes +import org.xml.sax.helpers.DefaultHandler +import java.io.File +import java.io.InputStream +import java.util.Stack +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.SAXParserFactory + +object ApkLinkParser { + fun getSupportedLinks(apkFile: File): List { + val zip = ZipFile(apkFile) + val entry = zip.getEntry("AndroidManifest.xml") + val manifest = parse(zip.getInputStream(entry)) ?: return listOf() + return manifest.getElementsByTagName("intent-filter").run { + (0 until length).flatMap { index -> + val intentFilter = item(index) + val schemes = mutableListOf() + val hosts = mutableListOf() + val paths = mutableListOf() + + intentFilter.childNodes.run { + for (i in 0 until length) { + val node = item(i) + fun data(name: String) = node.attributes.getNamedItem(name)?.nodeValue + if (node.nodeName == "data") { + data("android:scheme")?.let { schemes.add(it) } + data("android:host")?.let { hosts.add(it) } + data("android:path")?.let { + paths.add(it) + } + } + } + + schemes.flatMap { scheme -> + hosts.flatMap { host -> + if (paths.isEmpty()) listOf("$scheme://$host") + else paths.map { path -> "$scheme://$host$path" } + } + } + } + } + } + } + + private fun parse(input: InputStream) = runCatching { + val xmlDom = XmlDom() + runCatching { + CompressedXmlParser(xmlDom).parse(input) + }.getOrElse { + NonCompressedXmlParser(xmlDom).parse(input) + } + xmlDom.document + }.getOrNull() + + + class Attribute { + var name: String? = null + var prefix: String? = null + var namespace: String? = null + var value: String? = null + } + + class XmlDom { + val document: Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + private val mStack = Stack() + private fun isEmpty(text: String?) = text == null || "" == text + fun startDocument() { + mStack.push(document) + } + + fun startElement( + uri: String?, localName: String?, qName: String?, attrs: Array + ) { + val elt = if (isEmpty(uri)) document.createElement(localName) + else document.createElementNS(uri, qName) + + for (attr in attrs) { + if (isEmpty(attr.namespace)) elt.setAttribute(attr.name, attr.value) + else elt.setAttributeNS(attr.namespace, attr.prefix + ':' + attr.name, attr.value) + } + mStack.peek()!!.appendChild(elt) + mStack.push(elt) + } + + fun endElement() { + mStack.pop() + } + + fun text(data: String?) { + mStack.peek()!!.appendChild(document.createTextNode(data)) + } + + fun characterData(data: String?) { + mStack.peek()!!.appendChild(document.createCDATASection(data)) + } + } + + //NOT TESTED + class NonCompressedXmlParser(private val mListener: XmlDom) { + fun parse(input: InputStream) { + val factory = SAXParserFactory.newInstance() + val saxParser = factory.newSAXParser() + val handler = object : DefaultHandler() { + override fun startDocument() = mListener.startDocument() + override fun startElement( + uri: String?, localName: String?, qName: String, attributes: Attributes + ) { + val attrs = Array(attributes.length) { i -> + Attribute().apply { + name = attributes.getQName(i) + namespace = uri + value = attributes.getValue(i) + } + } + mListener.startElement(uri, localName, qName, attrs) + } + + override fun characters(ch: CharArray, start: Int, length: Int) { + val data = String(ch, start, length) + mListener.characterData(data) + } + + override fun endElement(uri: String?, localName: String?, qName: String) = + mListener.endElement() + + override fun endDocument() {} + } + + saxParser.parse(input, handler) + } + } + + class CompressedXmlParser(private val mListener: XmlDom) { + + fun parse(input: InputStream) { + mData = ByteArray(input.available()) + input.read(mData) + input.close() + parseCompressedXml() + } + + private fun parseCompressedXml() { + var word0: Int + + while (mParserOffset < mData.size) { + word0 = getLEWord(mParserOffset) + when (word0) { + WORD_START_DOCUMENT -> parseStartDocument() + WORD_STRING_TABLE -> parseStringTable() + WORD_RES_TABLE -> parseResourceTable() + WORD_START_NS -> parseNamespace(true) + WORD_END_NS -> parseNamespace(false) + WORD_START_TAG -> parseStartTag() + WORD_END_TAG -> parseEndTag() + WORD_TEXT -> parseText() + else -> mParserOffset += WORD_SIZE + } + } + } + + private fun parseStartDocument() { + mListener.startDocument() + mParserOffset += 2 * WORD_SIZE + } + + private fun parseStringTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mStringsCount = getLEWord(mParserOffset + (2 * WORD_SIZE)) + mStylesCount = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val strOffset = mParserOffset + getLEWord(mParserOffset + (5 * WORD_SIZE)) + mStringsTable = arrayOfNulls(mStringsCount) + var offset: Int + for (i in 0 until mStringsCount) { + offset = strOffset + getLEWord(mParserOffset + ((i + 7) * WORD_SIZE)) + mStringsTable[i] = getStringFromStringTable(offset) + } + mParserOffset += chunk + } + + private fun parseResourceTable() { + val chunk = getLEWord(mParserOffset + (1 * WORD_SIZE)) + mResCount = (chunk / 4) - 2 + mResourcesIds = IntArray(mResCount) + for (i in 0 until mResCount) { + mResourcesIds[i] = getLEWord(mParserOffset + ((i + 2) * WORD_SIZE)) + } + mParserOffset += chunk + } + + private fun parseNamespace(start: Boolean) { + val prefixIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val uriIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val uri = getString(uriIdx) + val prefix = getString(prefixIdx) + if (start) mNamespaces[uri] = prefix + else mNamespaces.remove(uri) + mParserOffset += 6 * WORD_SIZE + } + + private fun parseStartTag() { + val uriIdx = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val nameIdx = getLEWord(mParserOffset + (5 * WORD_SIZE)) + val attrCount = getLEShort(mParserOffset + (7 * WORD_SIZE)) + val name = getString(nameIdx) + val (uri, qName) = if (uriIdx == -0x1) "" to name else { + val uri = getString(uriIdx) + uri to if (mNamespaces.containsKey(uri)) mNamespaces[uri] + ':' + name + else name + } + mParserOffset += 9 * WORD_SIZE + val attrs = Array(attrCount) { + parseAttribute().also { mParserOffset += 5 * 4 } + } + mListener.startElement(uri, name, qName, attrs) + } + + private fun parseAttribute(): Attribute { + val attrNSIdx = getLEWord(mParserOffset) + val attrNameIdx = getLEWord(mParserOffset + (1 * WORD_SIZE)) + val attrValueIdx = getLEWord(mParserOffset + (2 * WORD_SIZE)) + val attrType = getLEWord(mParserOffset + (3 * WORD_SIZE)) + val attrData = getLEWord(mParserOffset + (4 * WORD_SIZE)) + + val attr = Attribute() + attr.name = getString(attrNameIdx) + + if (attrNSIdx == -0x1) { + attr.namespace = null + attr.prefix = null + } else { + val uri = getString(attrNSIdx) + if (mNamespaces.containsKey(uri)) { + attr.namespace = uri + attr.prefix = mNamespaces[uri] + } + } + attr.value = if (attrValueIdx == -0x1) getAttributeValue(attrType, attrData) + else getString(attrValueIdx) + + return attr + } + + private fun parseText() { + val strIndex = getLEWord(mParserOffset + (4 * WORD_SIZE)) + val data = getString(strIndex) + mListener.characterData(data) + mParserOffset += 7 * WORD_SIZE + } + + private fun parseEndTag() { + mListener.endElement() + mParserOffset += 6 * WORD_SIZE + } + + private fun getString(index: Int): String? { + val res = if (index in 0.. getString(data) + TYPE_ID_REF -> String.format("@id/0x%08X", data) + TYPE_ATTR_REF -> String.format("?id/0x%08X", data) + else -> String.format("%08X/0x%08X", type, data) + } + + private val mNamespaces: MutableMap = HashMap() + private lateinit var mData: ByteArray + + private lateinit var mStringsTable: Array + private lateinit var mResourcesIds: IntArray + private var mStringsCount = 0 + private var mStylesCount = 0 + private var mResCount = 0 + private var mParserOffset = 0 + + companion object { + const val WORD_START_DOCUMENT: Int = 0x00080003 + + const val WORD_STRING_TABLE: Int = 0x001C0001 + const val WORD_RES_TABLE: Int = 0x00080180 + + const val WORD_START_NS: Int = 0x00100100 + const val WORD_END_NS: Int = 0x00100101 + const val WORD_START_TAG: Int = 0x00100102 + const val WORD_END_TAG: Int = 0x00100103 + const val WORD_TEXT: Int = 0x00100104 + const val WORD_SIZE: Int = 4 + + private const val TYPE_ID_REF = 0x01000008 + private const val TYPE_ATTR_REF = 0x02000008 + private const val TYPE_STRING = 0x03000008 + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoFragment.kt new file mode 100644 index 0000000..1519d33 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoFragment.kt @@ -0,0 +1,332 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceGroup +import androidx.preference.SwitchPreferenceCompat +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.SettingCategory +import dev.brahmkshatriya.echo.common.settings.SettingItem +import dev.brahmkshatriya.echo.common.settings.SettingList +import dev.brahmkshatriya.echo.common.settings.SettingMultipleChoice +import dev.brahmkshatriya.echo.common.settings.SettingOnClick +import dev.brahmkshatriya.echo.common.settings.SettingSlider +import dev.brahmkshatriya.echo.common.settings.SettingSwitch +import dev.brahmkshatriya.echo.common.settings.SettingTextInput +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.extensionPrefId +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.toSettings +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.STREAM_QUALITY +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.streamQualities +import dev.brahmkshatriya.echo.ui.settings.BaseSettingsFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.PermsUtils.registerActivityResultLauncher +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.exportExtensionSettings +import dev.brahmkshatriya.echo.utils.importExtensionSettings +import dev.brahmkshatriya.echo.utils.ui.prefs.LoadingPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialListPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialMultipleChoicePreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialSliderPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialTextInputPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.TransitionPreference +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class ExtensionInfoFragment : BaseSettingsFragment() { + private val args by lazy { requireArguments() } + private val name by lazy { args.getString("name")!! } + private val extensionId by lazy { args.getString("id")!! } + private val extensionType by lazy { args.getString("type")!! } + private val extIcon by lazy { args.getSerialized("icon")?.getOrThrow() } + + private val viewModel by viewModel { + parametersOf(ExtensionType.valueOf(extensionType), extensionId) + } + private val extensionsViewModel by activityViewModel() + + override val title get() = name + override val icon get() = extIcon + override val creator = { ExtensionPreference().apply { arguments = args } } + + companion object { + fun getBundle( + name: String, id: String, type: ExtensionType, icon: ImageHolder?, + ) = Bundle().apply { + putString("name", name) + putString("id", id) + putString("type", type.name) + putSerialized("icon", icon) + } + + fun getBundle(extension: Extension<*>) = + getBundle(extension.name, extension.id, extension.type, extension.metadata.icon) + + fun Activity.openLink(url: String) { + val intent = Intent(Intent.ACTION_VIEW).apply { data = url.toUri() } + runCatching { startActivity(intent) } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observe(viewModel.stateFlow) { state -> + val extension = state?.extension ?: return@observe + val metadata = extension.metadata + binding.toolBar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_uninstall -> { + parentFragmentManager.popBackStack() + extensionsViewModel.uninstall(requireActivity(), extension) + true + } + + R.id.menu_repo -> { + requireActivity().openLink(metadata.repoUrl!!) + true + } + + R.id.menu_update -> { + extensionsViewModel.update(extension) + true + } + + else -> false + } + } + if (metadata.importType != ImportType.BuiltIn) { + binding.toolBar.menu.clear() + binding.toolBar.inflateMenu(R.menu.extensions_menu) + if (metadata.repoUrl == null) binding.toolBar.menu.removeItem(R.id.menu_repo) + } + } + } + + class ExtensionPreference : PreferenceFragmentCompat() { + private val extensionId by lazy { arguments?.getString("id")!! } + private val extensionType by lazy { arguments?.getString("type")!! } + private val viewModel by lazy { + requireParentFragment().viewModel { + parametersOf(ExtensionType.valueOf(extensionType), extensionId) + }.value + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + configure() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val context = preferenceManager.context + preferenceManager.sharedPreferencesName = extensionPrefId(extensionType, extensionId) + preferenceManager.sharedPreferencesMode = Context.MODE_PRIVATE + val prefs = preferenceManager.sharedPreferences ?: return + val settings = toSettings(prefs) + prefs.registerOnSharedPreferenceChangeListener { _, key -> + viewModel.onSettingsChanged(settings, key) + } + preferenceManager.setOnPreferenceTreeClickListener { + viewModel.onSettingsChanged(settings, it.key) + true + } + observe(viewModel.stateFlow) { state -> + val extension = state?.extension + val screen = preferenceManager.createPreferenceScreen(context) + preferenceScreen = screen + if (extension == null) { + screen.addPreference(LoadingPreference(context)) + return@observe + } + val infoPreference = ExtensionInfoPreference( + this@ExtensionPreference, extension, state.isLoginClient + ) + screen.addPreference(infoPreference) + if (extension.type == ExtensionType.MUSIC) MaterialListPreference(context).apply { + key = STREAM_QUALITY + title = getString(R.string.stream_quality) + summary = getString(R.string.x_specific_quality_summary, extension.name) + entries = + context.resources.getStringArray(R.array.stream_qualities) + getString(R.string.off) + entryValues = streamQualities + "off" + layoutResource = R.layout.preference + isIconSpaceReserved = false + setDefaultValue("off") + screen.addPreference(this) + } + state.settings.forEach { it.addPreferenceTo(screen) } + + TransitionPreference(context).apply { + key = "export" + title = getString(R.string.export_settings) + summary = getString(R.string.export_settings_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + screen.addPreference(this) + setOnPreferenceClickListener { + val contract = ActivityResultContracts.CreateDocument("application/json") + requireActivity().registerActivityResultLauncher(contract) { uri -> + uri?.let { + context.exportExtensionSettings(extensionType, extensionId, it) + } + }.launch("echo-$extensionType-$extensionId-settings.json".lowercase()) + true + } + } + + TransitionPreference(context).apply { + key = "import" + title = getString(R.string.import_settings) + summary = getString(R.string.import_settings_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + screen.addPreference(this) + setOnPreferenceClickListener { + val contract = ActivityResultContracts.OpenDocument() + requireActivity().registerActivityResultLauncher(contract) { + it?.let { + context.importExtensionSettings(extensionType, extensionId, it) + requireActivity().recreate() + } + }.launch(arrayOf("application/json")) + true + } + } + } + } + + private fun Setting.addPreferenceTo(preferenceGroup: PreferenceGroup) { + val context = preferenceGroup.context + when (this) { + is SettingCategory -> { + PreferenceCategory(context).also { + it.title = this.title + it.key = this.key + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference_category + preferenceGroup.addPreference(it) + + this.items.forEach { item -> + item.addPreferenceTo(it) + } + } + } + + is SettingItem -> { + Preference(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference + preferenceGroup.addPreference(it) + } + } + + is SettingSwitch -> { + SwitchPreferenceCompat(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + it.setDefaultValue(this.defaultValue) + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference_switch + preferenceGroup.addPreference(it) + } + } + + is SettingList -> { + MaterialListPreference(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + defaultEntryIndex?.let { index -> + it.setDefaultValue(this.entryValues[index]) + } + it.entries = this.entryTitles.toTypedArray() + it.entryValues = this.entryValues.toTypedArray() + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference + preferenceGroup.addPreference(it) + } + } + + is SettingMultipleChoice -> { + MaterialMultipleChoicePreference(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + defaultEntryIndices?.let { indices -> + it.setDefaultValue(indices.mapNotNull { index -> + entryValues.getOrNull(index) + }.toSet()) + } + it.entries = this.entryTitles.toTypedArray() + it.entryValues = this.entryValues.toTypedArray() + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference + preferenceGroup.addPreference(it) + } + } + + is SettingTextInput -> { + MaterialTextInputPreference(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + it.text = this.defaultValue + + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference + preferenceGroup.addPreference(it) + } + } + + is SettingSlider -> { + MaterialSliderPreference(context, from, to, steps, allowOverride).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + it.setDefaultValue(this.defaultValue) + + it.isIconSpaceReserved = false + preferenceGroup.addPreference(it) + } + } + + is SettingOnClick -> { + TransitionPreference(context).also { + it.title = this.title + it.key = this.key + it.summary = this.summary + it.setOnPreferenceClickListener { + viewModel.onSettingsClick(onClick) + true + } + it.isIconSpaceReserved = false + it.layoutResource = R.layout.preference + preferenceGroup.addPreference(it) + } + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoPreference.kt new file mode 100644 index 0000000..44c1fb5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoPreference.kt @@ -0,0 +1,136 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import android.annotation.SuppressLint +import android.text.SpannableString +import android.text.Spanned +import android.text.method.LinkMovementMethod +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.databinding.ItemLoginUserBinding +import dev.brahmkshatriya.echo.databinding.PreferenceExtensionInfoBinding +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toEntity +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoFragment.Companion.openLink +import dev.brahmkshatriya.echo.ui.extensions.login.LoginFragment +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.addOnDestroyObserver +import dev.brahmkshatriya.echo.utils.ui.SimpleItemSpan +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class ExtensionInfoPreference( + private val fragment: Fragment, + private val extension: Extension<*>, + private val isLoginClient: Boolean, +) : Preference(fragment.requireContext()) { + + private val viewModel by fragment.activityViewModel() + private val loginViewModel by fragment.activityViewModel() + + init { + layoutResource = R.layout.preference_extension_info + if (isLoginClient) { + loginViewModel.currentExtension.value = extension + fragment.addOnDestroyObserver { + loginViewModel.currentExtension.value = viewModel.extensionLoader.current.value + } + } + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: PreferenceViewHolder) { + val binding = PreferenceExtensionInfoBinding.bind(holder.itemView) + val metadata = extension.metadata + binding.extensionDetails.text = + "${metadata.version} • ${metadata.importType.name}" + + val byAuthor = fragment.getString(R.string.by_x, metadata.author) + val type = getType(extension.type) + val typeString = fragment.getString(R.string.x_extension, fragment.getString(type)) + val span = SpannableString("$typeString\n\n${metadata.description}\n\n$byAuthor") + val authUrl = metadata.authorUrl + if (authUrl != null) { + val itemSpan = SimpleItemSpan(context) { + fragment.requireActivity().openLink(authUrl) + } + val start = span.length - metadata.author.length + span.setSpan(itemSpan, start, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + binding.extensionDescription.text = span + binding.extensionDescription.movementMethod = LinkMovementMethod.getInstance() + + fun updateText(enabled: Boolean) { + binding.extensionEnabledText.text = fragment.getString( + if (enabled) R.string.enabled else R.string.disabled + ) + } + binding.extensionEnabledSwitch.apply { + updateText(metadata.isEnabled) + isChecked = metadata.isEnabled + setOnCheckedChangeListener { _, isChecked -> + updateText(isChecked) + viewModel.setExtensionEnabled(extension.type, metadata.id, isChecked) + } + binding.extensionEnabled.setOnClickListener { toggle() } + } + if (isLoginClient) binding.extensionLoginUser.bind(fragment) + else binding.extensionLoginUser.root.isVisible = false + } + + companion object { + fun getType(type: ExtensionType) = when (type) { + ExtensionType.MUSIC -> R.string.music + ExtensionType.TRACKER -> R.string.tracker + ExtensionType.LYRICS -> R.string.lyrics + ExtensionType.MISC -> R.string.misc + } + + private fun ItemLoginUserBinding.bind(fragment: Fragment) = with(fragment) { + val viewModel by activityViewModel() + val binding = this@bind + + binding.switchAccount.setOnClickListener { + LoginUserListBottomSheet().show(parentFragmentManager, null) + } + observe(viewModel.currentUser) { user -> + binding.login.isVisible = user == null + binding.notLoggedInContainer.isVisible = user == null + + binding.logout.isVisible = user != null + binding.userContainer.isVisible = user != null + + val ext = viewModel.currentExtension.value + binding.login.setOnClickListener { + ext ?: return@setOnClickListener + requireActivity().openFragment( + null, + LoginFragment.getBundle(ext.id, ext.name, ext.type) + ) + } + + binding.logout.setOnClickListener { + ext ?: return@setOnClickListener + viewModel.logout(user?.toEntity(ext.type, ext.id)) + viewModel.setLoginUser(CurrentUser(ext.type, ext.id, null)) + } + + binding.incognito.setOnClickListener { + ext ?: return@setOnClickListener + viewModel.setLoginUser(CurrentUser(ext.type, ext.id, null)) + } + + binding.currentUserName.text = user?.name + binding.currentUserSubTitle.text = user?.subtitle ?: ext?.name + user?.cover.loadInto(binding.currentUserAvatar, R.drawable.ic_account_circle) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoViewModel.kt new file mode 100644 index 0000000..25cedf8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInfoViewModel.kt @@ -0,0 +1,71 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.common.clients.SettingsChangeListenerClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.providers.SettingsProvider +import dev.brahmkshatriya.echo.common.settings.Setting +import dev.brahmkshatriya.echo.common.settings.Settings +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.get +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.runIf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class ExtensionInfoViewModel( + val app: App, + val extensionLoader: ExtensionLoader, + val type: ExtensionType, + val id: String, +) : ViewModel() { + + private val reload = MutableSharedFlow(1).also { + viewModelScope.launch { it.emit(Unit) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val stateFlow = extensionLoader.getFlow(type).combine(reload) { a, _ -> a } + .transformLatest { list -> + emit(null) + val ext = list.find { it.id == id } ?: return@transformLatest + emit( + State( + ext, + ext.isClient(), + ext.isClient(), + ext.getIf>(app.throwFlow) { + getSettingItems() + }.orEmpty() + ) + ) + }.stateIn(viewModelScope, Eagerly, null) + + data class State( + val extension: Extension<*>?, + val isLoginClient: Boolean, + val isPlaylistEditClient: Boolean, + val settings: List, + ) + + fun onSettingsChanged(settings: Settings, key: String?) = viewModelScope.launch { + stateFlow.value?.extension?.runIf(app.throwFlow) { + onSettingsChanged(settings, key) + } + } + + fun onSettingsClick(onClick: suspend () -> Unit) = viewModelScope.launch { + stateFlow.value?.extension?.get { onClick() }?.getOrThrow(app.throwFlow) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInstallerBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInstallerBottomSheet.kt new file mode 100644 index 0000000..77bad86 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionInstallerBottomSheet.kt @@ -0,0 +1,133 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.databinding.DialogExtensionInstallerBinding +import dev.brahmkshatriya.echo.extensions.exceptions.ExtensionLoaderException +import dev.brahmkshatriya.echo.extensions.repo.ExtensionParser.Companion.PACKAGE_FLAGS +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoPreference.Companion.getType +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import java.io.File + +class ExtensionInstallerBottomSheet : BottomSheetDialogFragment() { + + companion object { + fun newInstance( + file: File, + ) = ExtensionInstallerBottomSheet().apply { + arguments = Bundle().apply { + putString("file", file.absolutePath) + } + } + + fun Context.createLinksDialog( + file: File, links: List + ): AlertDialog = MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.allow_opening_links)) + .setMessage( + links.joinToString("\n") + "\n" + getString(R.string.open_links_instruction) + ) + .setPositiveButton(getString(R.string.okay)) { dialog, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val packageName = packageManager + .getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)?.packageName + intent.setData("package:$packageName".toUri()) + startActivity(intent) + dialog.dismiss() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> dialog.dismiss() } + .show() + } + + private var binding by autoCleared() + private val viewModel by activityViewModel() + + private val args by lazy { requireArguments() } + private val file by lazy { File(args.getString("file")!!) } + private val supportedLinks by lazy { + runCatching { ApkLinkParser.getSupportedLinks(file) }.getOrNull().orEmpty() + } + private val metadata by lazy { + runCatching { viewModel.extensionLoader.parser.parseManifest(file, ImportType.File) } + } + + override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { + binding = DialogExtensionInstallerBinding.inflate(inflater, parent, false) + return binding.root + } + + private var install = false + private var installAsApk = false + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.topAppBar.setNavigationOnClickListener { dismiss() } + val metadata = metadata.getOrElse { + lifecycleScope.launch { + viewModel.app.throwFlow.emit( + ExtensionLoaderException("ExtensionInstaller", file.toString(), it) + ) + } + dismiss() + return + } + + binding.extensionTitle.text = metadata.name + metadata.icon + .loadAsCircle(binding.extensionIcon, R.drawable.ic_extension_32dp) { + binding.extensionIcon.setImageDrawable(it) + } + binding.extensionDetails.text = metadata.version + + val byAuthor = getString(R.string.by_x, metadata.author) + val type = getType(metadata.type) + val typeString = getString(R.string.x_extension, getString(type)) + binding.extensionDescription.text = "$typeString\n\n${metadata.description}\n\n$byAuthor" + + val isSupported = supportedLinks.isNotEmpty() + binding.installationTypeTitle.isVisible = isSupported + binding.installationTypeGroup.isVisible = isSupported + binding.installationTypeSummary.isVisible = isSupported + binding.installationTypeLinks.isVisible = isSupported + binding.installationTypeWarning.isVisible = false + + installAsApk = isSupported + if (isSupported) { + binding.installationTypeLinks.text = supportedLinks.joinToString("\n") + binding.installationTypeGroup.addOnButtonCheckedListener { group, _, _ -> + installAsApk = group.checkedButtonId == R.id.appInstall + binding.installationTypeWarning.isVisible = !installAsApk + } + } + + binding.installButton.setOnClickListener { + install = true + dismiss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + val id = metadata.getOrNull()?.id + val type = if (installAsApk) ImportType.App else ImportType.File + viewModel.promptDismissed(file, install, type, id ?: "", supportedLinks) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionOpenerActivity.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionOpenerActivity.kt new file mode 100644 index 0000000..c93e370 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionOpenerActivity.kt @@ -0,0 +1,43 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.core.net.toFile +import dev.brahmkshatriya.echo.MainActivity.Companion.getMainActivity +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.extensions.InstallationUtils.getTempFile +import dev.brahmkshatriya.echo.utils.ContextUtils.getTempFile +import java.io.File + +class ExtensionOpenerActivity : Activity() { + override fun onStart() { + super.onStart() + val uri = intent.data + + val file = runCatching { + when (uri?.scheme) { + "content" -> getTempFile(uri) + "file" -> getTempFile(uri.toFile()) + else -> null + } + }.getOrNull() + + if (file == null) Toast.makeText( + this, getString(R.string.could_not_find_the_file), Toast.LENGTH_SHORT + ).show() + + finish() + val startIntent = Intent(this, getMainActivity()) + startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startIntent.data = file?.let { Uri.fromFile(it) } + startActivity(startIntent) + } + + private fun getTempFile(file: File): File { + val tempFile = getTempFile() + file.copyTo(tempFile) + return tempFile + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt new file mode 100644 index 0000000..806fbf4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/ExtensionsViewModel.kt @@ -0,0 +1,236 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.ImportType +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.InstallationUtils.installApp +import dev.brahmkshatriya.echo.extensions.InstallationUtils.installFile +import dev.brahmkshatriya.echo.extensions.InstallationUtils.uninstallApp +import dev.brahmkshatriya.echo.extensions.InstallationUtils.uninstallFile +import dev.brahmkshatriya.echo.extensions.db.models.ExtensionEntity +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInstallerBottomSheet.Companion.createLinksDialog +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionListViewModel +import dev.brahmkshatriya.echo.utils.AppUpdater.downloadUpdate +import dev.brahmkshatriya.echo.utils.AppUpdater.getUpdateFileUrl +import dev.brahmkshatriya.echo.utils.AppUpdater.updateApp +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import dev.brahmkshatriya.echo.utils.ContextUtils.cleanupTempApks +import dev.brahmkshatriya.echo.utils.ContextUtils.collect +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.File + +class ExtensionsViewModel( + val extensionLoader: ExtensionLoader, + val app: App +) : ExtensionListViewModel() { + override val extensionsFlow = extensionLoader.music + override val currentSelectionFlow = extensionLoader.current + override fun onExtensionSelected(extension: MusicExtension) { + extensionLoader.setupMusicExtension(extension, true) + } + + private val extensionDao = extensionLoader.db.extensionDao() + fun setExtensionEnabled(extensionType: ExtensionType, id: String, checked: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + extensionDao.setExtension(ExtensionEntity(id, extensionType, checked)) + } + } + + fun changeExtension(id: String) { + viewModelScope.launch { + runCatching { + val ext = extensionLoader.music.getExtensionOrThrow(id) + extensionLoader.setupMusicExtension(ext, true) + }.getOrElse { + app.throwFlow.emit(it) + } + } + } + + val lastSelectedManageExt = MutableStateFlow(0) + val manageExtListFlow = extensionLoader.all.combine(lastSelectedManageExt) { _, last -> + extensionLoader.getFlow(ExtensionType.entries[last]).value + } + + fun moveExtensionItem(toPos: Int, fromPos: Int) { + val type = ExtensionType.entries[lastSelectedManageExt.value] + val flow = extensionLoader.priorityMap[type]!! + val list = extensionLoader.getFlow(type).value.map { it.id }.toMutableList() + list.add(toPos, list.removeAt(fromPos)) + flow.value = list + } + + private val updateTime = 1000 * 60 * 60 * 2 // Check every 2hrs + private fun shouldCheckForExtensionUpdates(): Boolean { + val check = app.settings.getBoolean("check_for_updates", true) + if (!check) return false + val lastUpdateCheck = app.context.getFromCache("last_update_check") ?: 0 + return System.currentTimeMillis() - lastUpdateCheck > updateTime + } + + private suspend fun message(msg: String) { + app.messageFlow.emit(Message(msg)) + } + + fun update(activity: FragmentActivity, force: Boolean) = viewModelScope.launch { + if (!force && !shouldCheckForExtensionUpdates()) return@launch + activity.saveToCache("last_update_check", System.currentTimeMillis()) + activity.cleanupTempApks() + message(app.context.getString(R.string.checking_for_updates)) + val appApk = updateApp(app) + runCatching { + if (appApk != null) { + activity.saveToCache("last_update_check", 0) + awaitInstallation(appApk).getOrThrow() + } else extensionLoader.all.value.forEach { updateExt(it) } + }.getOrElse { app.throwFlow.emit(it) } + } + + data class PromptResult( + val file: File, + val accepted: Boolean, + val type: ImportType, + val id: String, + val supportedLinks: List + ) + + val installPromptFlow = MutableSharedFlow() + private val promptResultFlow = MutableSharedFlow() + val installFileFlow = MutableSharedFlow() + val installedFlow = MutableSharedFlow>>() + val linksDialogFlow = MutableSharedFlow>>() + + private suspend fun install(id: String, type: ImportType, file: File): Result { + return if (type == ImportType.App) awaitInstallation(file) + else runCatching { installFile(app.context, extensionLoader.fileIgnoreFlow, id, file) } + } + + private suspend fun awaitInstallation(file: File): Result { + installFileFlow.emit(file) + return installedFlow.first { it.first == file }.second + } + + fun promptDismissed( + file: File, install: Boolean, type: ImportType, id: String, supportedLinks: List + ) = viewModelScope.launch { + promptResultFlow.emit(PromptResult(file, install, type, id, supportedLinks)) + } + + private suspend fun updateExt(ext: Extension<*>, show: Boolean = false) { + val file = getExtensionUpdate(ext, show) ?: return + install(ext.id, ext.metadata.importType, file).onFailure { + app.throwFlow.emit(it) + return + } + message(app.context.getString(R.string.extension_updated_successfully, ext.name)) + } + + fun update(extension: Extension<*>) = viewModelScope.launch { updateExt(extension, true) } + + fun installWithPrompt(files: List) = viewModelScope.launch { + files.forEach { file -> + installPromptFlow.emit(file) + val result = promptResultFlow.first { it.file == file } + if (!result.accepted) return@forEach + install(result.id, result.type, result.file).onFailure { + app.throwFlow.emit(it) + return@forEach + } + message(app.context.getString(R.string.extension_installed_successfully)) + if (result.type == ImportType.App) + linksDialogFlow.emit(file to result.supportedLinks) + } + } + + fun uninstall(activity: FragmentActivity, extension: Extension<*>) = viewModelScope.launch { + val fileResult = runCatching { + uninstallFile(extensionLoader.fileIgnoreFlow, extension.metadata.path) + }.exceptionOrNull() + val appResult = runCatching { + uninstallApp(activity, extension.metadata.path) + }.exceptionOrNull() + val result = if (extension.metadata.importType == ImportType.App) appResult else fileResult + if (result == null) message(app.context.getString(R.string.extension_uninstalled_successfully)) + else app.throwFlow.emit(result) + } + + companion object { + fun FragmentActivity.configureExtensionsUpdater() { + val viewModel by viewModel() + collect(viewModel.installPromptFlow) { + ExtensionInstallerBottomSheet.newInstance(it).show(supportFragmentManager, null) + } + collect(viewModel.linksDialogFlow) { + createLinksDialog(it.first, it.second) + } + + viewModel.update(this, false) + var currentFile: File? = null + collect(viewModel.installFileFlow) { + currentFile = it + viewModel.installedFlow.emit(it to runCatching { installApp(this, it) }) + } + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + val file = currentFile ?: return + viewModel.run { + viewModelScope.launch { + installedFlow.emit( + file to Result.failure(CancellationException()) + ) + } + } + } + }) + } + } + + private val client = OkHttpClient() + private suspend fun getExtensionUpdate( + extension: Extension<*>, + show: Boolean = false + ): File? { + val currentVersion = extension.version + val updateUrl = extension.metadata.updateUrl ?: return null + val url = runCatching { + getUpdateFileUrl(currentVersion, updateUrl, client).getOrThrow() + }.recoverCatching { + throw it.toAppException(extension) + }.getOrThrow(app.throwFlow) + if (url == null) { + if (show) message( + app.context.getString(R.string.no_update_available_for_x, extension.name) + ) + return null + } + message(app.context.getString(R.string.downloading_update_for_x, extension.name)) + val file = runCatching { + downloadUpdate(app.context, url, client).getOrThrow() + }.recoverCatching { + throw it.toAppException(extension) + }.getOrThrow(app.throwFlow) ?: return null + return file + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/WebViewUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/WebViewUtils.kt new file mode 100644 index 0000000..489d921 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/WebViewUtils.kt @@ -0,0 +1,353 @@ +package dev.brahmkshatriya.echo.ui.extensions + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.view.View +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebStorage +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.add +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.acsbendi.requestinspectorwebview.RequestInspectorWebViewClient +import com.google.android.material.progressindicator.LinearProgressIndicator +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.WebViewRequest +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.NetworkRequest +import dev.brahmkshatriya.echo.databinding.FragmentGenericCollapsableBinding +import dev.brahmkshatriya.echo.databinding.FragmentWebviewBinding +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.addIfNull +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.extensions.login.LoginFragment.Companion.bind +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +object WebViewUtils { + private const val USER_AGENT = + "Mozilla/5.0 (Linux; Android 2; Jeff Bezos) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36" + + @Suppress("DEPRECATION") + @SuppressLint("SetJavaScriptEnabled") + fun FragmentActivity.configure( + webView: WebView, + progress: LinearProgressIndicator, + target: WebViewRequest, + skipTimeout: Boolean, + onComplete: suspend (Result?) -> Unit, + ): OnBackPressedCallback { + val callback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + webView.goBack() + } + } + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().run { + removeAllCookies(null) + flush() + } + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + userAgentString = USER_AGENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + isAlgorithmicDarkeningAllowed = true + } + runCatching { + webView.load(lifecycleScope, progress, callback, target, skipTimeout, onComplete) + }.getOrElse { + lifecycleScope.launch { + webView.stop(callback) + onComplete(Result.failure(it)) + } + } + return callback + } + + private fun WebView.load( + scope: CoroutineScope, + progress: LinearProgressIndicator, + callback: OnBackPressedCallback, + target: WebViewRequest, + skipTimeout: Boolean, + onComplete: suspend (Result?) -> Unit, + ) { + val stopRegex = target.stopUrlRegex + val interceptRegex = + if (target is WebViewRequest.Headers) target.interceptUrlRegex else null + val timeout = target.maxTimeout + val bridge = Bridge() + val requests = mutableListOf() + val timeoutJob = if (!skipTimeout) scope.launch { + delay(timeout) + onComplete( + Result.failure( + Exception( + "WebView request timed out after $timeout ms\nParsed Links:\n" + + requests.joinToString("\n") { it.url } + ) + ) + ) + } else null + webViewClient = object : RequestInspectorWebViewClient(this@load) { + override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { + callback.isEnabled = canGoBack() + } + + var done = false + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + progress.show() + if (done) return + if (target !is WebViewRequest.Evaluate) return + target.javascriptToEvaluateOnPageStart?.let { js -> + scope.launch { + runCatching { evalJS(null, js) }.onFailure { + stop(callback) + onComplete(Result.failure(it)) + } + } + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + progress.hide() + } + + val mutex = Mutex() + fun intercept(request: NetworkRequest) { + if (target is WebViewRequest.Headers) { + if (interceptRegex == null || interceptRegex.matches(request.url)) { + requests.add(request) + } + } + if (stopRegex.find(request.url) == null) return + timeoutJob?.cancel() + scope.launch(Dispatchers.IO) { + mutex.withLock { + if (done) return@withLock + done = true + onComplete(null) + val result = runCatching { + val headerRes = if (target is WebViewRequest.Headers) + target.onStop(requests) + else null + val cookieRes = if (target is WebViewRequest.Cookie) { + val cookie = + CookieManager.getInstance().getCookie(request.url) ?: "" + target.onStop(request, cookie) + } else null + val evalRes = if (target is WebViewRequest.Evaluate) + target.onStop( + request, + evalJS(bridge, target.javascriptToEvaluate) + ) + else null + evalRes ?: cookieRes ?: headerRes!! + } + stop(callback) + onComplete(result) + } + } + } + + override fun shouldInterceptRequest( + view: WebView, webViewRequest: com.acsbendi.requestinspectorwebview.WebViewRequest, + ): WebResourceResponse? { + intercept( + webViewRequest.run { + NetworkRequest( + NetworkRequest.Method.valueOf(method), + url, + headers, + body.encodeToByteArray() + ) + } + ) + return null + } + + override fun shouldOverrideUrlLoading( + view: WebView?, request: WebResourceRequest?, + ): Boolean { + request?.run { + val headers = requestHeaders?.toMutableMap() ?: mutableMapOf() + val cookie = CookieManager.getInstance().getCookie(url.toString()) + if (cookie != null) headers["Cookie"] = cookie + intercept(NetworkRequest(url.toString(), headers)) + } + return false + } + } + + addJavascriptInterface(bridge, "bridge") + settings.cacheMode = + if (runCatching { target.dontCache }.getOrNull() == true) WebSettings.LOAD_NO_CACHE + else WebSettings.LOAD_DEFAULT + target.initialUrl.run { + settings.userAgentString = lowerCaseHeaders["user-agent"] ?: settings.userAgentString + loadUrl(url, headers) + } + } + + suspend fun WebView.evalJS(bridge: Bridge?, js: String) = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { + bridge?.onResult = it::resume + bridge?.onError = it::resumeWithException + val asyncFunction = if (js.startsWith("async function")) js + else if (js.startsWith("function")) "async $js" + else { + it.resumeWithException(Exception("Invalid JS function, must start with async or function")) + return@suspendCancellableCoroutine + } + val newJs = """ + (function() { + try { + const fun = $asyncFunction; + fun().then((result) => { + bridge.putJsResult(result); + }).catch((error) => { + bridge.putJsError(error.message || error.toString()); + }); + } catch (error) { + bridge.putJsError(error.message || error.toString()); + } + })() + """.trimIndent() + evaluateJavascript(newJs, null) + + it.invokeOnCancellation { + evaluateJavascript("javascript:window.stop();", null) + } + } + } + + suspend fun WebView.stop( + callback: OnBackPressedCallback, + ) = withContext(Dispatchers.Main) { + loadUrl("about:blank") + callback.isEnabled = false + } + + @Suppress("unused") + class Bridge { + var onError: ((Throwable) -> Unit)? = null + var onResult: ((String?) -> Unit)? = null + + @JavascriptInterface + fun putJsResult(result: String?) { + onResult?.invoke(result) + } + + @JavascriptInterface + fun putJsError(error: String?) { + onError?.invoke(Exception(error ?: "Unknown JavaScript error")) + } + } + + fun FragmentActivity.onWebViewIntent( + intent: Intent, + ) { + val id = intent.getIntExtra("webViewRequest", -1) + if (id == -1) return + val extensionLoader by inject() + val webViewClient = extensionLoader.webViewClientFactory + val wrapper = webViewClient.requests[id] ?: return + createSnack(Message(getString(R.string.opening_webview_x, wrapper.reason))) + if (wrapper.showWebView) openFragment(null, getBundle(id)) + else supportFragmentManager.commit { + add(R.id.hiddenWebViewContainer, null, getBundle(id)) + } + } + + private fun getBundle(id: Int) = Bundle().apply { + putInt("webViewRequest", id) + } + + class Hidden : Fragment(R.layout.fragment_webview) { + private val vm by activityViewModel() + private val webViewClient by lazy { vm.extensionLoader.webViewClientFactory } + private val wrapper by lazy { + val id = requireArguments().getInt("webViewRequest") + webViewClient.requests[id] + } + private val shouldRemove by lazy { + requireArguments().getBoolean("hidden", true) + } + + private fun removeSelf() { + if (shouldRemove) parentFragmentManager.commit(true) { remove(this@Hidden) } + else parentFragment?.parentFragmentManager?.popBackStack() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentWebviewBinding.bind(view) + val wrapper = wrapper ?: run { + removeSelf() + return + } + val callback = requireActivity().configure( + binding.webview, + binding.progress, + wrapper.request, + false + ) { + webViewClient.responseFlow.emit(wrapper to it) + if (it == null) runCatching { removeSelf() } + } + if (!shouldRemove) { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) + applyBackPressCallback() + } + } + } + + class WithAppbar : Fragment(R.layout.fragment_generic_collapsable) { + private val vm by activityViewModel() + private val webViewClient by lazy { vm.extensionLoader.webViewClientFactory } + private val wrapper by lazy { + val id = requireArguments().getInt("webViewRequest") + webViewClient.requests[id] ?: throw IllegalStateException("Invalid webview request") + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentGenericCollapsableBinding.bind(view) + binding.bind(this) + binding.toolBar.title = wrapper.extension.name + wrapper.extension.icon.loadAsCircle( + binding.extensionIcon, R.drawable.ic_extension_32dp + ) { + binding.extensionIcon.setImageDrawable(it) + } + addIfNull(R.id.genericFragmentContainer, "webview", arguments?.apply { + putBoolean("hidden", false) + }) + } + } +} + diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/AddViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/AddViewModel.kt new file mode 100644 index 0000000..f6685df --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/AddViewModel.kt @@ -0,0 +1,121 @@ +package dev.brahmkshatriya.echo.ui.extensions.add + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.exceptions.InvalidExtensionListException +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.utils.AppUpdater.downloadUpdate +import dev.brahmkshatriya.echo.utils.AppUpdater.getUpdateFileUrl +import dev.brahmkshatriya.echo.utils.Serializer.toData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +class AddViewModel( + private val app: App, + private val extensionLoader: ExtensionLoader +) : ViewModel() { + + fun getList() = (addingFlow.value as? AddState.AddList)?.list.orEmpty() + + fun selectAll(select: Boolean) { + val list = getList() + addingFlow.value = AddState.AddList(list.map { it.copy(isChecked = select) }) + } + + fun toggleItem(item: ExtensionAssetResponse, isChecked: Boolean) { + val list = getList() + val index = list.indexOfFirst { it.item.id == item.id } + if (index == -1) return + val currentItem = list[index] + addingFlow.value = AddState.AddList(list.toMutableList().apply { + set(index, currentItem.copy(isChecked = isChecked)) + }) + } + + private val client = OkHttpClient() + private suspend fun getExtensionList( + link: String, + client: OkHttpClient + ) = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .addHeader("Cookie", "preview=1") + .url(link).build() + client.newCall(request).await().body.string() + .toData>().getOrThrow() + } + }.getOrElse { + throw InvalidExtensionListException(link, it) + } + + @Serializable + data class ExtensionAssetResponse( + val id: String, + val name: String, + val subtitle: String? = null, + val iconUrl: String? = null, + val updateUrl: String + ) + + sealed class AddState { + data object Init : AddState() + data object Loading : AddState() + data class AddList(val list: List?) : AddState() + data class Downloading(val item: ExtensionAssetResponse) : AddState() + data class Final(val files: List) : AddState() + } + + var opened = false + val addingFlow = MutableStateFlow(AddState.Init) + fun addFromLinkOrCode(link: String) = viewModelScope.launch { + addingFlow.value = AddState.Loading + val actualLink = when { + link.startsWith("http://") or link.startsWith("https://") -> link + else -> "https://v.gd/$link" + } + + val list = runCatching { getExtensionList(actualLink, client) }.getOrElse { + app.throwFlow.emit(it) + null + } + val installed = extensionLoader.all.value.map { it.id } + val shouldBeChecked = (list?.size ?: 0) <= 3 + addingFlow.value = AddState.AddList(list?.map { + val isInstalled = it.id in installed + ExtensionsAddListAdapter.Item( + it, + isChecked = shouldBeChecked && !isInstalled, + isInstalled = isInstalled + ) + }) + } + + fun download( + download: Boolean, extensionsViewModel: ExtensionsViewModel + ) = viewModelScope.launch { + val selected = + if (download) getList().filter { it.isChecked }.map { it.item } else listOf() + val files = selected.mapNotNull { item -> + addingFlow.value = AddState.Downloading(item) + val url = getUpdateFileUrl("", item.updateUrl, client).getOrElse { + app.throwFlow.emit(it) + null + } ?: return@mapNotNull null + downloadUpdate(app.context, url, client).getOrElse { + app.throwFlow.emit(it) + null + } + } + extensionsViewModel.installWithPrompt(files) + addingFlow.value = AddState.Final(files) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt new file mode 100644 index 0000000..d4d5a21 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddBottomSheet.kt @@ -0,0 +1,96 @@ +package dev.brahmkshatriya.echo.ui.extensions.add + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.DialogExtensionAddBinding +import dev.brahmkshatriya.echo.extensions.InstallationUtils.openFileSelector +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class ExtensionsAddBottomSheet : BottomSheetDialogFragment() { + + var binding by autoCleared() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = DialogExtensionAddBinding.inflate(inflater) + return binding.root + } + + val viewModel by viewModel() + private val extensionViewModel by activityViewModel() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val context = requireActivity() + binding.topAppBar.setNavigationOnClickListener { dismiss() } + binding.installationTypeGroup.addOnButtonCheckedListener { group, _, _ -> + val isLink = group.checkedButtonId == R.id.linkAdd + binding.textInputLayout.isVisible = isLink + } + + binding.editText.setOnEditorActionListener { _, _, _ -> + binding.installButton.performClick() + true + } + + binding.installButton.setOnClickListener { + val isLink = binding.installationTypeGroup.checkedButtonId == R.id.linkAdd + if (!isLink) lifecycleScope.launch { + val file = runCatching { requireActivity().openFileSelector() }.getOrNull() + extensionViewModel.installWithPrompt(listOfNotNull(file)) + dismissAllowingStateLoss() + } else { + val link = binding.editText.text.toString() + if (link.isEmpty()) return@setOnClickListener + viewModel.addFromLinkOrCode(link) + } + } + + observe(viewModel.addingFlow) { + val loading = it != AddViewModel.AddState.Init + binding.loading.root.isVisible = loading + binding.nestedScrollView.isVisible = !loading + when (it) { + AddViewModel.AddState.Init -> {} + AddViewModel.AddState.Loading -> { + binding.loading.textView.text = context.getString(R.string.loading) + } + + is AddViewModel.AddState.AddList -> if (it.list != null) { + if (it.list.isEmpty()) { + createSnack(R.string.list_is_empty) + dismiss() + } else { + if (viewModel.opened) return@observe + viewModel.opened = true + ExtensionsAddListBottomSheet().show(childFragmentManager, null) + } + } else dismiss() + + is AddViewModel.AddState.Downloading -> { + binding.loading.textView.text = context.getString( + R.string.downloading_x, it.item.name + ) + } + + is AddViewModel.AddState.Final -> { + viewModel.opened = false + viewModel.addingFlow.value = AddViewModel.AddState.Init + dismiss() + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListAdapter.kt new file mode 100644 index 0000000..e094e1d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListAdapter.kt @@ -0,0 +1,126 @@ +package dev.brahmkshatriya.echo.ui.extensions.add + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.databinding.ItemExtensionAddBinding +import dev.brahmkshatriya.echo.databinding.ItemExtensionAddFooterBinding +import dev.brahmkshatriya.echo.databinding.ItemExtensionAddHeaderBinding +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class ExtensionsAddListAdapter( + val listener: Listener +) : ListAdapter(DiffCallback) { + + data class Item( + val item: AddViewModel.ExtensionAssetResponse, + val isChecked: Boolean, + val isInstalled: Boolean + ) + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item) = + oldItem.item.id == newItem.item.id + + override fun areContentsTheSame(oldItem: Item, newItem: Item) = + oldItem == newItem + } + + fun interface Listener { + fun onChecked(item: AddViewModel.ExtensionAssetResponse, isChecked: Boolean) + } + + inner class ViewHolder( + val binding: ItemExtensionAddBinding + ) : ScrollAnimViewHolder(binding.root) { + init { + binding.extensionSwitch.setOnCheckedChangeListener { _, checked -> + val item = runCatching { getItem(bindingAdapterPosition) }.getOrNull() + if (item == null) return@setOnCheckedChangeListener + listener.onChecked(item.item, checked) + } + binding.root.setOnClickListener { + binding.extensionSwitch.isChecked = !binding.extensionSwitch.isChecked + } + binding.extensionSwitch.isClickable = false + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemExtensionAddBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val (item, isChecked, isInstalled) = getItem(position) + val binding = holder.binding + binding.extensionName.text = if (!isInstalled) item.name + else binding.root.context.getString(R.string.extension_installed, item.name) + binding.extensionSubtitle.text = item.subtitle ?: item.id + binding.itemExtension.apply { + item.iconUrl?.toImageHolder().loadAsCircle(this, R.drawable.ic_extension_32dp) { + setImageDrawable(it) + } + } + binding.extensionSwitch.isChecked = isChecked + } + + class Header( + val listener: Listener + ) : RecyclerView.Adapter() { + + interface Listener { + fun onClose() + fun onSelectAllChanged(select: Boolean) + } + + class ViewHolder(val binding: ItemExtensionAddHeaderBinding) : + ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemExtensionAddHeaderBinding.inflate(inflater, parent, false) + binding.toolBar.setNavigationOnClickListener { listener.onClose() } + binding.selectAll.setOnCheckedChangeListener { _, isChecked -> + listener.onSelectAllChanged(isChecked) + } + return ViewHolder(binding) + } + + override fun getItemCount() = 1 + + override fun onBindViewHolder(holder: ViewHolder, position: Int) {} + + } + + class Footer( + val listener: Listener + ) : RecyclerView.Adapter() { + + fun interface Listener { + fun onAdd() + } + + class ViewHolder(val binding: ItemExtensionAddFooterBinding) : + ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemExtensionAddFooterBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun getItemCount() = 1 + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.binding.root.setOnClickListener { listener.onAdd() } + } + + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListBottomSheet.kt new file mode 100644 index 0000000..02e5c40 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/add/ExtensionsAddListBottomSheet.kt @@ -0,0 +1,61 @@ +package dev.brahmkshatriya.echo.ui.extensions.add + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.ConcatAdapter +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.databinding.DialogExtensionsAddListBinding +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class ExtensionsAddListBottomSheet : BottomSheetDialogFragment() { + var binding by autoCleared() + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = DialogExtensionsAddListBinding.inflate(inflater, container, false) + return binding.root + } + + private var clicked = false + private val viewModel by lazy { + requireParentFragment().viewModel().value + } + private val extensionsViewModel by activityViewModel() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val extensionListAdapter = ExtensionsAddListAdapter { item, isChecked -> + viewModel.toggleItem(item, isChecked) + } + + val headerAdapter = ExtensionsAddListAdapter.Header( + object : ExtensionsAddListAdapter.Header.Listener { + override fun onClose() = dismiss() + override fun onSelectAllChanged(select: Boolean) { + viewModel.selectAll(select) + } + } + ) + + val footerAdapter = ExtensionsAddListAdapter.Footer { + clicked = true + dismiss() + } + binding.root.adapter = ConcatAdapter(headerAdapter, extensionListAdapter, footerAdapter) + observe(viewModel.addingFlow) { + val list = viewModel.getList() + extensionListAdapter.submitList(list) + } + } + + override fun onDismiss(dialog: DialogInterface) { + viewModel.download(clicked, extensionsViewModel) + super.onDismiss(dialog) + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionListViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionListViewModel.kt new file mode 100644 index 0000000..c8785d2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionListViewModel.kt @@ -0,0 +1,18 @@ +package dev.brahmkshatriya.echo.ui.extensions.list + +import androidx.lifecycle.ViewModel +import dev.brahmkshatriya.echo.common.Extension +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +abstract class ExtensionListViewModel> : ViewModel() { + abstract val extensionsFlow: StateFlow> + abstract val currentSelectionFlow: MutableStateFlow + fun selectExtension(index: Int) { + val extension = extensionsFlow.value.getOrNull(index) ?: return + currentSelectionFlow.value = extension + onExtensionSelected(extension) + } + + open fun onExtensionSelected(extension: T) {} +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionsListBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionsListBottomSheet.kt new file mode 100644 index 0000000..da32cb5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/list/ExtensionsListBottomSheet.kt @@ -0,0 +1,117 @@ +package dev.brahmkshatriya.echo.ui.extensions.list + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.button.MaterialButtonToggleGroup.OnButtonCheckedListener +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.databinding.DialogExtensionsListBinding +import dev.brahmkshatriya.echo.databinding.ItemExtensionButtonBinding +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoPreference.Companion.getType +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.ui.extensions.add.ExtensionsAddBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.manage.ManageExtensionsFragment +import dev.brahmkshatriya.echo.ui.player.more.lyrics.LyricsViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class ExtensionsListBottomSheet : BottomSheetDialogFragment() { + + companion object { + fun newInstance(type: ExtensionType) = ExtensionsListBottomSheet().apply { + arguments = Bundle().apply { + putString("type", type.name) + } + } + } + + private var binding by autoCleared() + private val args by lazy { requireArguments() } + private val type by lazy { ExtensionType.valueOf(args.getString("type")!!) } + + override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { + binding = DialogExtensionsListBinding.inflate(inflater, parent, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.topAppBar.setNavigationOnClickListener { dismiss() } + binding.topAppBar.title = getString(R.string.x_extensions, getString(getType(type))) + + binding.addExtension.setOnClickListener { + dismiss() + ExtensionsAddBottomSheet().show(parentFragmentManager, null) + } + + binding.manageExtension.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + val viewModel = when (type) { + ExtensionType.MUSIC -> activityViewModel().value + ExtensionType.LYRICS -> activityViewModel().value + else -> throw IllegalStateException("Not supported") + } + + binding.topAppBar.setOnMenuItemClickListener { + val extension = + viewModel.currentSelectionFlow.value ?: return@setOnMenuItemClickListener false + requireActivity().openFragment( + null, ExtensionInfoFragment.getBundle(extension) + ) + dismiss() + true + } + + val listener = object : OnButtonCheckedListener { + var enabled = false + override fun onButtonChecked( + group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean + ) { + if (isChecked && enabled) { + viewModel.selectExtension(checkedId) + dismiss() + } + } + } + binding.buttonToggleGroup.addOnButtonCheckedListener(listener) + val extensionFlow = viewModel.extensionsFlow + observe(extensionFlow) { list -> + binding.buttonToggleGroup.removeAllViews() + listener.enabled = false + val selected = viewModel.currentSelectionFlow.value + list.forEachIndexed { index, extension -> + if (!extension.isEnabled) return@forEachIndexed + val button = ItemExtensionButtonBinding.inflate( + layoutInflater, + binding.buttonToggleGroup, + false + ).root + button.text = extension.name + binding.buttonToggleGroup.addView(button) + button.isChecked = extension == selected + extension.metadata.icon.loadAsCircle(button) { + if (it != null) { + button.icon = it + button.iconTint = null + } else button.setIconResource(R.drawable.ic_extension_32dp) + } + button.id = index + } + + val checked = list.indexOf(selected).takeIf { it != -1 } + if (checked != null) binding.buttonToggleGroup.check(checked) + listener.enabled = true + } + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginFragment.kt new file mode 100644 index 0000000..caed390 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginFragment.kt @@ -0,0 +1,303 @@ +package dev.brahmkshatriya.echo.ui.extensions.login + +import android.os.Bundle +import android.text.InputType.TYPE_CLASS_NUMBER +import android.text.InputType.TYPE_CLASS_TEXT +import android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS +import android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD +import android.text.InputType.TYPE_TEXT_VARIATION_URI +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.fragment.app.replace +import androidx.lifecycle.lifecycleScope +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.common.clients.LoginClient.InputField.Type +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.databinding.FragmentExtensionLoginCustomInputBinding +import dev.brahmkshatriya.echo.databinding.FragmentExtensionLoginSelectorBinding +import dev.brahmkshatriya.echo.databinding.FragmentGenericCollapsableBinding +import dev.brahmkshatriya.echo.databinding.FragmentWebviewBinding +import dev.brahmkshatriya.echo.databinding.ItemExtensionButtonBinding +import dev.brahmkshatriya.echo.databinding.ItemInputBinding +import dev.brahmkshatriya.echo.extensions.exceptions.AppException +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.extensions.WebViewUtils.configure +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class LoginFragment : Fragment() { + companion object { + fun getBundle(extId: String, extName: String, extensionType: ExtensionType) = bundleOf( + "extId" to extId, + "extName" to extName, + "extensionType" to extensionType.name, + ) + + fun getBundle(error: AppException.LoginRequired) = + getBundle(error.extension.id, error.extension.name, error.extension.type) + + + fun FragmentGenericCollapsableBinding.bind( + fragment: Fragment, applyInsets: Boolean = true + ) = with(fragment) { + setupTransition(root) + if (applyInsets) applyInsets { + genericFragmentContainer.applyContentInsets(it) + } + applyBackPressCallback() + appBarLayout.configureAppBar { offset -> + toolbarOutline.alpha = offset + iconContainer.alpha = 1 - offset + } + toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + } + + private fun getIcon(type: Type) = when (type) { + Type.Email -> R.drawable.ic_email + Type.Password -> R.drawable.ic_password + Type.Number -> R.drawable.ic_numbers + Type.Url -> R.drawable.ic_language + Type.Username -> R.drawable.ic_account_circle + Type.Misc -> R.drawable.ic_input + } + } + + private var binding by autoCleared() + private val clientType by lazy { + val type = requireArguments().getString("extensionType")!! + ExtensionType.valueOf(type) + } + private val extId by lazy { requireArguments().getString("extId")!! } + private val extName by lazy { requireArguments().getString("extName")!! } + private val loginViewModel by viewModel { + parametersOf(clientType, extId) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentGenericCollapsableBinding.inflate(inflater, container, false) + return binding.root + } + + private inline fun add(args: Bundle? = null) { + if (!isAdded) return + childFragmentManager.run { + loginViewModel.loading.value = false + commit { + setReorderingAllowed(true) + if (fragments.isNotEmpty()) addToBackStack(null) + replace(R.id.genericFragmentContainer, null, args) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.bind(this) + binding.toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + binding.toolBar.title = getString(R.string.x_login, extName) + + observe(loginViewModel.extension) { ext -> + ext?.metadata?.icon.loadAsCircle(binding.extensionIcon, R.drawable.ic_extension_32dp) { + binding.extensionIcon.setImageDrawable(it) + lifecycleScope.launch { + delay(2000) + binding.appBarLayout.setExpanded(false) + } + } + } + + observe(loginViewModel.loading) { + binding.genericFragmentContainer.isVisible = !it + binding.loading.root.isVisible = it + } + + observe(loginViewModel.loadingOver) { + repeat(childFragmentManager.backStackEntryCount) { + parentFragmentManager.popBackStack() + } + parentFragmentManager.popBackStack() + } + + observe(loginViewModel.addFragmentFlow) { + when (it) { + LoginViewModel.FragmentType.Selector -> add(arguments) + LoginViewModel.FragmentType.WebView -> add(arguments) + is LoginViewModel.FragmentType.CustomInput -> add(Bundle().apply { + putAll(arguments) + putInt("formIndex", it.index ?: 0) + }) + } + } + } + + class Selector : Fragment(R.layout.fragment_extension_login_selector) { + private val loginViewModel by lazy { + requireParentFragment().viewModel().value + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + val binding = FragmentExtensionLoginSelectorBinding.bind(view) + val client = loginViewModel.extension.value?.instance?.value + val clients = listOfNotNull( + if (client is LoginClient.WebView) { + val button = ItemExtensionButtonBinding.inflate( + layoutInflater, binding.loginToggleGroup, false + ).root + button.text = getString(R.string.webview) + button.setIconResource(R.drawable.ic_language) + button to { loginViewModel.changeFragment(LoginViewModel.FragmentType.WebView) } + } else null, + *(if (client is LoginClient.CustomInput) { + val forms = runCatching { client.forms }.getOrNull().orEmpty() + forms.mapIndexed { index, it -> + val button = ItemExtensionButtonBinding.inflate( + layoutInflater, binding.loginToggleGroup, false + ).root + button.text = it.label + button.setIconResource(getIcon(it.icon)) + button to { + loginViewModel.changeFragment( + LoginViewModel.FragmentType.CustomInput(index) + ) + } + } + } else listOf()).toTypedArray(), + ) + clients.forEachIndexed { index, pair -> + val button = pair.first + button.setOnClickListener { pair.second() } + binding.loginToggleGroup.addView(button) + button.id = index + } + } + } + + class WebView : Fragment(R.layout.fragment_webview) { + private val loginViewModel by lazy { + requireParentFragment().viewModel().value + } + private val extension by lazy { + loginViewModel.extension.value + } + private val webViewRequest by lazy { + (extension?.instance?.value as? LoginClient.WebView)?.webViewRequest + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view, axis = MaterialSharedAxis.X) + val binding = FragmentWebviewBinding.bind(view) + val req = webViewRequest ?: return + val callback = requireActivity().configure( + binding.webview, binding.progress, req, true + ) { + if (it == null) loginViewModel.loading.value = true + else loginViewModel.onWebViewStop(it) + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) + } + } + + class CustomInput : Fragment(R.layout.fragment_extension_login_custom_input) { + private val loginViewModel by lazy { + requireParentFragment().viewModel().value + } + private val extension by lazy { loginViewModel.extension.value } + private val formIndex by lazy { requireArguments().getInt("formIndex", 0) } + private val form by lazy { + (extension?.instance?.value as? LoginClient.CustomInput)?.forms?.getOrNull(formIndex) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view, axis = MaterialSharedAxis.X) + val form = form ?: run { + message(Message("No form found for extension ${extension?.id}")) + parentFragmentManager.popBackStack() + return + } + val binding = FragmentExtensionLoginCustomInputBinding.bind(view) + binding.run { + form.inputFields.forEachIndexed { index, field -> + val input = ItemInputBinding.inflate( + layoutInflater, customInput, false + ) + input.root.id = field.key.hashCode() + input.editText.id = "${field.key}_input".hashCode() + input.root.hint = field.label + input.root.setStartIconDrawable(getIcon(field.type)) + @Suppress("DEPRECATION") + input.root.isPasswordVisibilityToggleEnabled = field.type == Type.Password + input.editText.inputType = when (field.type) { + Type.Email -> TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_EMAIL_ADDRESS + Type.Password -> TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_PASSWORD + Type.Number -> TYPE_CLASS_TEXT or TYPE_CLASS_NUMBER + Type.Url -> TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_URI + else -> TYPE_CLASS_TEXT + } + input.editText.setText(loginViewModel.inputs[field.key]) + input.editText.doAfterTextChanged { editable -> + loginViewModel.inputs[field.key] = + editable.toString().takeIf { it.isNotBlank() } + } + input.editText.setOnEditorActionListener { _, _, _ -> + if (index < form.inputFields.size - 1) { + customInput.getChildAt(index + 1).requestFocus() + } else loginCustomSubmit.performClick() + true + } + + customInput.addView(input.root) + } + loginCustomSubmit.setOnClickListener { + form.inputFields.forEach { + if (it.isRequired && loginViewModel.inputs[it.key].isNullOrEmpty()) { + message(Message(getString(R.string.x_is_required, it.label))) + return@setOnClickListener + } + val regex = it.regex + if (regex != null && !loginViewModel.inputs[it.key].isNullOrEmpty()) { + if (!loginViewModel.inputs[it.key]!!.matches(regex)) { + message( + Message( + getString(R.string.regex_invalid, it.label, regex.pattern) + ) + ) + return@setOnClickListener + } + } + } + loginViewModel.onCustomTextInputSubmit(form) + } + } + } + + private fun message(m: Message) { + lifecycleScope.launch { + loginViewModel.messageFlow.emit(m) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListBottomSheet.kt new file mode 100644 index 0000000..a7a654e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListBottomSheet.kt @@ -0,0 +1,99 @@ +package dev.brahmkshatriya.echo.ui.extensions.login + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButtonToggleGroup.OnButtonCheckedListener +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.DialogLoginUserListBinding +import dev.brahmkshatriya.echo.databinding.ItemExtensionButtonBinding +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toEntity +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class LoginUserListBottomSheet : BottomSheetDialogFragment() { + + var binding by autoCleared() + val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = DialogLoginUserListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.title.setNavigationOnClickListener { dismiss() } + + var listener: OnButtonCheckedListener? = null + observe(viewModel.allUsers) { (ext, list) -> + binding.accountListLoading.root.isVisible = ext == null + binding.accountListToggleGroup.isVisible = ext != null + binding.title.title = getString(R.string.select_x_account, ext?.name ?: "") + binding.accountListToggleGroup.removeAllViews() + listener?.let { binding.accountListToggleGroup.removeOnButtonCheckedListener(it) } + + ext ?: return@observe + + binding.title.setOnMenuItemClickListener { + dismiss() + viewModel.setLoginUser(CurrentUser(ext.type, ext.id, null)) + true + } + + binding.addAccount.setOnClickListener { + dismiss() + requireActivity().openFragment( + null, + LoginFragment.getBundle(ext.id, ext.name, ext.type) + ) + } + + val selectedUser = viewModel.currentUser.value + + binding.logout.isEnabled = selectedUser != null + binding.logout.setOnClickListener { + viewModel.logout(selectedUser?.toEntity(ext.type, ext.id)) + viewModel.setLoginUser(CurrentUser(ext.type, ext.id, null)) + } + + list.forEachIndexed { index, (user, selected) -> + val button = ItemExtensionButtonBinding.inflate( + layoutInflater, binding.accountListToggleGroup, false + ).root + button.text = user.name + binding.accountListToggleGroup.addView(button) + button.isChecked = selected + user.cover.loadAsCircle(button) { + if (it != null) { + button.icon = it + button.iconTint = null + } else button.setIconResource(R.drawable.ic_account_circle) + } + button.id = index + } + + val checked = list.indexOfFirst { it.second }.takeIf { it != -1 } + if (checked != null) binding.accountListToggleGroup.check(checked) + + listener = OnButtonCheckedListener { _, id, isChecked -> + if (isChecked) { + val user = list[id].first + viewModel.setLoginUser(user.toEntity(ext.type, ext.id)) + dismiss() + } + } + binding.accountListToggleGroup.addOnButtonCheckedListener(listener) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListViewModel.kt new file mode 100644 index 0000000..842388e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginUserListViewModel.kt @@ -0,0 +1,81 @@ +package dev.brahmkshatriya.echo.ui.extensions.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.db.models.CurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toCurrentUser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class LoginUserListViewModel( + val extensionLoader: ExtensionLoader, +) : ViewModel() { + + private val userDao = extensionLoader.db.userDao() + val currentExtension = MutableStateFlow?>(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val allUsers = currentExtension + .combine(extensionLoader.all) { ext, all -> + if (ext == null) return@combine null + all.find { it.type == ext.type && it.id == ext.id } + } + .combine(userDao.observeCurrentUser()) { a, b -> a to b } + .flatMapLatest { (ext, current) -> + val curr = current.find { it.extId == ext?.id && it.type == ext.type } + val flow = if (ext != null) userDao.observeAllUsers(ext.type, ext.id) + else null + flow?.map { entities -> + val users = entities.mapNotNull { ent -> + val selected = ent.id == curr?.userId + ent.user.getOrNull()?.let { it to selected } + } + ext to users + } ?: emptyFlow() + }.stateIn(viewModelScope, SharingStarted.Eagerly, null to listOf()) + + @OptIn(ExperimentalCoroutinesApi::class) + val allUsersWithClient = allUsers.mapLatest { + val (ext, users) = it + val isLoginClient = ext?.isClient() ?: false + Triple(ext, isLoginClient, users) + }.stateIn(viewModelScope, SharingStarted.Eagerly, Triple(null, false, emptyList())) + + val currentUser = allUsers.map { + it.second.find { user -> user.second }?.first + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun logout(user: UserEntity?) { + if (user == null) return + viewModelScope.launch(Dispatchers.IO) { + userDao.deleteUser(user) + } + } + + fun setLoginUser(user: UserEntity?) { + val currentUser = user?.toCurrentUser() + ?: currentExtension.value?.let { CurrentUser(it.type, it.id, null) } + ?: return + setLoginUser(currentUser) + } + + fun setLoginUser(currentUser: CurrentUser) { + viewModelScope.launch(Dispatchers.IO) { + userDao.setCurrentUser(currentUser) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginViewModel.kt new file mode 100644 index 0000000..f3befb6 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/login/LoginViewModel.kt @@ -0,0 +1,119 @@ +package dev.brahmkshatriya.echo.ui.extensions.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.LoginClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.get +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionFlow +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toCurrentUser +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toEntity +import dev.brahmkshatriya.echo.extensions.exceptions.AppException.Companion.toAppException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class LoginViewModel( + val extensionLoader: ExtensionLoader, + val extensionType: ExtensionType, + val extensionId: String? = null, +) : ViewModel() { + val extension = extensionLoader.getFlow(extensionType).getExtensionFlow(extensionId) + .stateIn(viewModelScope, Eagerly, null) + + private val app = extensionLoader.app + val messageFlow = app.messageFlow + private val userDao = extensionLoader.db.userDao() + val loading = MutableStateFlow(true) + val loadingOver = MutableSharedFlow() + val addFragmentFlow = MutableSharedFlow() + + sealed class FragmentType { + data object Selector : FragmentType() + data object WebView : FragmentType() + data class CustomInput(val index: Int?) : FragmentType() + } + + private suspend fun loginNotSupported(extName: String?) { + val login = app.context.getString(R.string.login) + val message = + app.context.getString(R.string.x_is_not_supported_in_x, login, extName.toString()) + messageFlow.emit(Message(message)) + loadingOver.emit(Unit) + } + + fun changeFragment(type: FragmentType) = viewModelScope.launch { + addFragmentFlow.emit(type) + } + + private suspend fun afterLogin( + result: Result> + ) { + val users = result.getOrElse { + app.throwFlow.emit(it) + loading.value = false + loadingOver.emit(Unit) + return@afterLogin + } + if (users.isEmpty()) { + app.messageFlow.emit(Message(app.context.getString(R.string.no_user_found))) + } else { + val entities = users.map { it.toEntity(extensionType, extensionId!!) } + userDao.insertUsers(entities) + val user = entities.first() + userDao.setCurrentUser(user.toCurrentUser()) + } + loading.value = false + loadingOver.emit(Unit) + } + + fun onWebViewStop( + result: Result>, + ) = viewModelScope.launch { + val extension = extension.first { it != null }!! + val users = runCatching { result.getOrElse { throw it.toAppException(extension) } } + afterLogin(users) + } + + val inputs = mutableMapOf() + fun onCustomTextInputSubmit(form: LoginClient.Form) = viewModelScope.launch { + loading.value = true + val extension = extension.first { it != null }!! + val users = extension.getAs> { + onLogin(form.key, inputs.toMap()) + } + afterLogin(users) + } + + init { + viewModelScope.launch { + val extension = extension.first { it != null }!! + val totalClients = extension.get { + listOfNotNull( + if (this is LoginClient.WebView) 1 else 0, + if (this is LoginClient.CustomInput) forms.size + else 0, + ).sum() + }.getOrNull() ?: 0 + val client = extension.instance.value().getOrNull() + when (totalClients) { + 0 -> loginNotSupported(extension.name) + 1 -> when (client) { + is LoginClient.WebView -> addFragmentFlow.emit(FragmentType.WebView) + is LoginClient.CustomInput -> addFragmentFlow.emit(FragmentType.CustomInput(null)) + null -> loginNotSupported(extension.name) + } + + else -> addFragmentFlow.emit(FragmentType.Selector) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ExtensionAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ExtensionAdapter.kt new file mode 100644 index 0000000..833454c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ExtensionAdapter.kt @@ -0,0 +1,98 @@ +package dev.brahmkshatriya.echo.ui.extensions.manage + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.databinding.ItemExtensionBinding +import dev.brahmkshatriya.echo.extensions.ExtensionLoader.Companion.priorityKey +import dev.brahmkshatriya.echo.ui.feed.EmptyAdapter +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class +ExtensionAdapter( + val listener: Listener +) : PagingDataAdapter, ExtensionAdapter.ViewHolder>(DiffCallback) { + + interface Listener { + fun onClick(extension: Extension<*>, view: View) + fun onDragHandleTouched(viewHolder: ViewHolder) + fun onOpenClick(extension: Extension<*>) + } + + object DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem == newItem + } + + private val empty = EmptyAdapter() + fun withEmptyAdapter() = ConcatAdapter(empty, this) + + class ViewHolder(val binding: ItemExtensionBinding, val listener: Listener) : + ScrollAnimViewHolder(binding.root) { + @SuppressLint("SetTextI18n") + fun bind(extension: Extension<*>) { + val metadata = extension.metadata + binding.root.transitionName = metadata.id + binding.root.setOnClickListener { + listener.onClick(extension, binding.root) + } + binding.extensionName.apply { + text = if (metadata.isEnabled) metadata.name + else context.getString(R.string.x_disabled, metadata.name) + } + binding.extensionVersion.text = "${metadata.version} • ${metadata.importType.name}" + binding.itemExtension.apply { + metadata.icon.loadAsCircle(this, R.drawable.ic_extension_32dp) { + setImageDrawable(it) + } + } + + binding.extensionDrag.setOnTouchListener { v, _ -> + v.performClick() + listener.onDragHandleTouched(this) + true + } + + binding.extensionUse.isVisible = extension.type == ExtensionType.MUSIC + binding.extensionUse.setOnClickListener { + listener.onOpenClick(extension) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return ViewHolder(ItemExtensionBinding.inflate(inflater, parent, false), listener) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val download = runCatching { getItem(position) }.getOrNull() ?: return + holder.bind(download) + } + + suspend fun submit(list: List>, selectedIndex: Int, settings: SharedPreferences) { + submitData(PagingData.from(list)) + empty.loadState = if (list.isEmpty()) LoadState.Loading + else LoadState.NotLoading(true) + // Update priority map of extensions + val key = ExtensionType.entries[selectedIndex].priorityKey() + val extIds = list.joinToString(",") { it.id } + settings.edit { putString(key, extIds) } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ManageExtensionsFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ManageExtensionsFragment.kt new file mode 100644 index 0000000..d29e015 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/extensions/manage/ManageExtensionsFragment.kt @@ -0,0 +1,134 @@ +package dev.brahmkshatriya.echo.ui.extensions.manage + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.tabs.TabLayout +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.databinding.FragmentManageExtensionsBinding +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsetsWithChild +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoPreference.Companion.getType +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.ui.extensions.add.ExtensionsAddBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class ManageExtensionsFragment : Fragment() { + private var binding by autoCleared() + private val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentManageExtensionsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + applyInsetsWithChild(binding.appBarLayout, binding.recyclerView, 104) { + binding.fabContainer.applyInsets(it) + } + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.appBarOutline.alpha = offset + binding.appBarOutline.isVisible = offset > 0 + binding.toolBar.alpha = 1 - offset + } + binding.toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + binding.toolBar.setOnMenuItemClickListener { + viewModel.update(requireActivity(), true) + true + } + + FastScrollerHelper.applyTo(binding.recyclerView) + binding.fabAddExtensions.setOnClickListener { + ExtensionsAddBottomSheet().show(parentFragmentManager, null) + } + + val tabs = ExtensionType.entries.map { + binding.tabLayout.newTab().apply { + setText(getType(it)) + } + } + binding.tabLayout.run { + tabs.forEach { addTab(it) } + } + + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0 + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + viewModel.moveExtensionItem(toPos, fromPos) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun getMovementFlags( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) + + } + + val touchHelper = ItemTouchHelper(callback) + val extensionAdapter = ExtensionAdapter(object : ExtensionAdapter.Listener { + override fun onClick(extension: Extension<*>, view: View) { + openFragment( + view, ExtensionInfoFragment.getBundle(extension) + ) + } + + override fun onDragHandleTouched(viewHolder: ExtensionAdapter.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + + override fun onOpenClick(extension: Extension<*>) { + viewModel.onExtensionSelected(extension as MusicExtension) + parentFragmentManager.popBackStack() + parentFragmentManager.popBackStack() + } + }) + + observe(viewModel.manageExtListFlow) { list -> + extensionAdapter.submit(list, viewModel.lastSelectedManageExt.value, viewModel.app.settings) + } + + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + fun select(tab: TabLayout.Tab) { + viewModel.lastSelectedManageExt.value = tab.position + } + + override fun onTabSelected(tab: TabLayout.Tab) = select(tab) + override fun onTabReselected(tab: TabLayout.Tab) = select(tab) + override fun onTabUnselected(tab: TabLayout.Tab) {} + }) + + binding.tabLayout.selectTab(tabs[viewModel.lastSelectedManageExt.value]) + binding.recyclerView.adapter = extensionAdapter.withEmptyAdapter() + touchHelper.attachToRecyclerView(binding.recyclerView) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/ButtonsAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/ButtonsAdapter.kt new file mode 100644 index 0000000..0122210 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/ButtonsAdapter.kt @@ -0,0 +1,145 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemFeedButtonsBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.animateVisibility +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class ButtonsAdapter( + private val viewModel: FeedData, + private val listener: FeedClickListener, + private val getAllLoaded: () -> List +) : ScrollAnimRecyclerAdapter(), GridAdapter { + override fun getItemCount() = 1 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(parent, viewModel, listener, getAllLoaded) + + var buttons: FeedData.Buttons? = null + set(value) { + field = value + notifyItemChanged(0) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + holder.bind(buttons) + } + + override fun onViewDetachedFromWindow(holder: ViewHolder) { + holder.onViewDetached() + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + + class ViewHolder( + parent: ViewGroup, + private val viewModel: FeedData, + private val listener: FeedClickListener, + private val getAllLoaded: () -> List, + private val binding: ItemFeedButtonsBinding = ItemFeedButtonsBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + + private var feed: FeedData.Buttons? = null + + init { + binding.searchToggleButton.addOnCheckedChangeListener { _, isChecked -> + viewModel.searchToggled = isChecked + if (!isChecked) { + binding.searchLayout.clearFocus() + viewModel.searchQuery = null + viewModel.onSearchClicked() + } + binding.searchBarContainer.animateVisibility(isChecked) + } + binding.searchBarText.addTextChangedListener { text -> + viewModel.searchQuery = text?.toString()?.takeIf { it.isNotBlank() } + binding.searchClose.isEnabled = !text.isNullOrBlank() + } + binding.searchClose.setOnClickListener { + binding.searchBarText.setText("") + viewModel.searchQuery = null + viewModel.onSearchClicked() + } + binding.searchBarText.setOnEditorActionListener { v, _, _ -> + viewModel.onSearchClicked() + true + } + binding.sortToggleButton.setOnClickListener { + listener.onSortClicked(it, feed?.feedId) + } + binding.playButton.setOnClickListener { + val list = if (feed?.item == null) { + feed?.buttons?.customTrackList ?: getAllLoaded() + } else null + listener.onPlayClicked(it, feed?.extensionId, feed?.item, list, false) + } + binding.shuffleButton.setOnClickListener { + val feed = feed + val list = if (feed?.item == null) { + feed?.buttons?.customTrackList ?: getAllLoaded() + } else null + listener.onPlayClicked(it, feed?.extensionId, feed?.item, list, true) + } + } + + fun bind(feed: FeedData.Buttons?) { + val buttons = feed?.buttons ?: Feed.Buttons.EMPTY + this.feed = feed + val showButtons = buttons.run { showPlayAndShuffle || showSort || showSearch } + binding.searchToggleButton.isChecked = viewModel.searchToggled + binding.searchBarText.setText(viewModel.searchQuery) + binding.chipGroup.configure(feed?.sortState) + binding.buttonGroup.isVisible = showButtons + if (!showButtons) return + binding.playButton.isVisible = buttons.showPlayAndShuffle + binding.shuffleButton.isVisible = buttons.showPlayAndShuffle + binding.searchToggleButton.isVisible = buttons.showSearch + binding.sortToggleButton.isVisible = buttons.showSort + } + + fun onViewDetached() { + binding.searchLayout.clearFocus() + } + + private fun ChipGroup.configure(state: FeedSort.State?) { + removeAllViews() + var visible = false + if (state?.feedSort != null) { + visible = true + val chip = Chip(context) + chip.text = context.getString(state.feedSort.title) + chip.isCheckable = true + chip.isChecked = true + addView(chip) + chip.setOnClickListener { + viewModel.feedSortState.value = state.copy(feedSort = null) + } + } + if (state?.reversed == true) { + visible = true + val chip = Chip(context) + chip.text = context.getString(R.string.reversed) + chip.isCheckable = true + chip.isChecked = true + addView(chip) + chip.setOnClickListener { + viewModel.feedSortState.value = state.copy(reversed = false) + } + } + isVisible = visible + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/EmptyAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/EmptyAdapter.kt new file mode 100644 index 0000000..2e39ec9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/EmptyAdapter.kt @@ -0,0 +1,22 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.LoadState +import dev.brahmkshatriya.echo.databinding.ItemShelfEmptyBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimLoadStateAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class EmptyAdapter : ScrollAnimLoadStateAdapter(), GridAdapter { + class ViewHolder(val binding: ItemShelfEmptyBinding) : ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemShelfEmptyBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedAdapter.kt new file mode 100644 index 0000000..5a637f2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedAdapter.kt @@ -0,0 +1,267 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemLoadingBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedData.FeedTab +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter.Companion.createListener +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.Category +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.CategoryGrid +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.Header +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.HorizontalList +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.Media +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.MediaGrid +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.Video +import dev.brahmkshatriya.echo.ui.feed.FeedType.Enum.VideoHorizontal +import dev.brahmkshatriya.echo.ui.feed.viewholders.CategoryViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.FeedViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.HeaderViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.HorizontalListViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaGridViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.VideoHorizontalViewHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.VideoViewHolder +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.animatedWithAlpha +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimPagingAdapter +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import java.lang.ref.WeakReference + +class FeedAdapter( + private val viewModel: FeedData, + private val listener: FeedClickListener, + private val takeFullScreen: Boolean = false, +) : ScrollAnimPagingAdapter>(DiffCallback), GridAdapter { + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: FeedType, newItem: FeedType) = oldItem == newItem + override fun areItemsTheSame(oldItem: FeedType, newItem: FeedType): Boolean { + if (oldItem.extensionId != newItem.extensionId) return false + if (newItem.type != oldItem.type) return false + if (oldItem.id != newItem.id) return false + return true + } + } + + private val viewPool = RecyclerView.RecycledViewPool() + override fun getItemViewType(position: Int) = + runCatching { getItem(position)!! }.getOrNull()?.type?.ordinal ?: 0 + + private var isPlayButtonShown = false + private fun FeedType.toTrack(): Track? = when (this) { + is FeedType.Media -> item as? Track + is FeedType.MediaGrid -> item as? Track + is FeedType.Video -> item + else -> null + } + + fun getAllTracks(feed: FeedType): Pair, Int> { + if (!isPlayButtonShown) return listOfNotNull(feed.toTrack()) to 0 + val list = snapshot().mapNotNull { it } + val index = list.indexOfFirst { it.id == feed.id } + if (index == -1) return listOf() to -1 + val from = list.take(index).indexOfLast { it.type != feed.type } + val to = list.drop(index + 1).indexOfFirst { it.type != feed.type } + val feeds = list.subList(from + 1, if (to == -1) list.size else index + to + 1) + val tracks = feeds.mapNotNull { it.toTrack() } + val newIndex = tracks.indexOfFirst { it.id == feed.id } + return tracks to newIndex + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeedViewHolder<*> { + val type = FeedType.Enum.entries[viewType] + return when (type) { + Header -> HeaderViewHolder(parent, listener) + HorizontalList -> HorizontalListViewHolder(parent, listener, viewPool) + Category -> CategoryViewHolder(parent, listener) + CategoryGrid -> CategoryViewHolder(parent, listener) + Media -> MediaViewHolder(parent, listener, ::getAllTracks) + MediaGrid -> MediaGridViewHolder(parent, listener, ::getAllTracks) + Video -> VideoViewHolder(parent, listener, ::getAllTracks) + VideoHorizontal -> VideoHorizontalViewHolder(parent, listener, ::getAllTracks) + } + } + + override fun onBindViewHolder(holder: FeedViewHolder<*>, position: Int) { + super.onBindViewHolder(holder, position) + val feed = runCatching { getItem(position) }.getOrNull() ?: return + when (holder) { + is HeaderViewHolder -> holder.bind(feed as FeedType.Header) + is CategoryViewHolder -> holder.bind(feed as FeedType.Category) + is MediaViewHolder -> holder.bind(feed as FeedType.Media) + is MediaGridViewHolder -> holder.bind(feed as FeedType.MediaGrid) + is VideoViewHolder -> holder.bind(feed as FeedType.Video) + is VideoHorizontalViewHolder -> holder.bind(feed as FeedType.Video) + is HorizontalListViewHolder -> { + holder.bind(feed as FeedType.HorizontalList) + viewModel.visibleScrollableViews[position] = WeakReference(holder) + holder.layoutManager.apply { + val state = viewModel.layoutManagerStates[position] + if (state != null) onRestoreInstanceState(state) + else scrollToPosition(0) + } + } + } + holder.onCurrentChanged(current) + } + + override fun onViewRecycled(holder: FeedViewHolder<*>) { + if (holder is HorizontalListViewHolder) saveScrollState(holder) { + viewModel.visibleScrollableViews.remove(holder.bindingAdapterPosition) + } + } + + override fun onViewAttachedToWindow(holder: FeedViewHolder<*>) { + holder.onCurrentChanged(current) + } + + class LoadingViewHolder( + parent: ViewGroup, + val binding: ItemLoadingBinding = ItemLoadingBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : FeedLoadingAdapter.ViewHolder(binding.root) { + init { + binding.textView.isVisible = false + } + + override fun bind(loadState: LoadState) { + binding.root.alpha = 0f + binding.root.animatedWithAlpha(500) + } + } + + fun getAllTracks() = snapshot().mapNotNull { + when (it) { + is FeedType.Media -> listOfNotNull(it.item as? Track) + is FeedType.MediaGrid -> listOfNotNull(it.item as? Track) + is FeedType.Video -> listOf(it.item) + is FeedType.HorizontalList -> it.shelf.list.filterIsInstance() + else -> null + } + }.flatten() + + fun withLoading(fragment: Fragment, vararg before: GridAdapter): GridAdapter.Concat { + val tabs = TabsAdapter({ tab.title }) { view, index, tab -> + listener.onTabSelected(view, tab.feedId, tab.extensionId, index) + } + fragment.observe(viewModel.tabsFlow) { tabs.data = it } + fragment.observe(viewModel.selectedTabIndexFlow) { tabs.selected = it } + val buttons = ButtonsAdapter(viewModel, listener, ::getAllTracks) + fragment.observe(viewModel.buttonsFlow) { + buttons.buttons = it + isPlayButtonShown = it?.buttons?.showPlayAndShuffle == true + } + val loadStateListener = fragment.createListener { retry() } + val header = FeedLoadingAdapter(loadStateListener) { LoadingViewHolder(it) } + val footer = FeedLoadingAdapter(loadStateListener) { LoadingViewHolder(it) } + val empty = EmptyAdapter() + fragment.observe( + loadStateFlow.combine(viewModel.shouldShowEmpty) { a, b -> a to b } + ) { (loadStates, shouldShowEmpty) -> + val isEmpty = + shouldShowEmpty && itemCount == 0 && loadStates.append is LoadState.NotLoading + empty.loadState = if (isEmpty) LoadState.Loading else LoadState.NotLoading(false) + } + addLoadStateListener { loadStates -> + header.loadState = loadStates.refresh + footer.loadState = loadStates.append + } + return GridAdapter.Concat(*before, tabs, buttons, header, empty, this, footer) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = + when (FeedType.Enum.entries[getItemViewType(position)]) { + Header, HorizontalList -> count + Category, Media, Video -> if (takeFullScreen) count else 2.coerceAtMost(count) + CategoryGrid, MediaGrid, VideoHorizontal -> 1 + } + + private fun clearState() { + viewModel.layoutManagerStates.clear() + viewModel.visibleScrollableViews.clear() + } + + private fun saveScrollState( + holder: HorizontalListViewHolder, block: ((HorizontalListViewHolder) -> Unit)? = null, + ) = runCatching { + val layoutManagerStates = viewModel.layoutManagerStates + layoutManagerStates[holder.bindingAdapterPosition] = + holder.layoutManager.onSaveInstanceState() + block?.invoke(holder) + } + + fun saveState() { + viewModel.visibleScrollableViews.values.forEach { item -> + item.get()?.let { saveScrollState(it) } + } + viewModel.visibleScrollableViews.clear() + } + + init { + addLoadStateListener { + if (it.refresh == LoadState.Loading) clearState() + } + } + + private var current: PlayerState.Current? = null + fun onCurrentChanged(current: PlayerState.Current?) { + this.current = current + onEachViewHolder { onCurrentChanged(current) } + } + + companion object { + fun Fragment.getFeedAdapter( + viewModel: FeedData, + listener: FeedClickListener, + takeFullScreen: Boolean = false, + ): FeedAdapter { + val playerViewModel by activityViewModel() + val adapter = FeedAdapter(viewModel, listener, takeFullScreen) + observe(viewModel.pagingFlow) { + adapter.saveState() + adapter.submitData(it) + } + observe(playerViewModel.playerState.current) { adapter.onCurrentChanged(it) } + return adapter + } + + fun getTouchHelper(listener: FeedClickListener) = ItemTouchHelper( + object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START) { + override fun getMovementFlags( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, + ): Int { + if (viewHolder !is MediaViewHolder) return 0 + if (viewHolder.feed?.item !is Track) return 0 + return makeMovementFlags(0, ItemTouchHelper.START) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val feed = (viewHolder as MediaViewHolder).feed ?: return + val track = feed.item as? Track ?: return + listener.onTrackSwiped(viewHolder.itemView, feed.extensionId, track) + viewHolder.bindingAdapter?.notifyItemChanged(viewHolder.bindingAdapterPosition) + } + + override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder) = 0.25f + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ) = false + } + ) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedClickListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedClickListener.kt new file mode 100644 index 0000000..622f5aa --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedClickListener.kt @@ -0,0 +1,172 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.media.MediaFragment +import dev.brahmkshatriya.echo.ui.media.MediaFragment.Companion.getBundle +import dev.brahmkshatriya.echo.ui.media.more.MediaMoreBottomSheet +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.ui.playlist.edit.search.EditPlaylistSearchClickListener +import org.koin.androidx.viewmodel.ext.android.viewModel + +open class FeedClickListener( + private val fragment: Fragment, + val fragmentManager: FragmentManager, + private val containerId: Int, + private val afterOpen: () -> Unit = {} +) { + companion object { + fun Fragment.getFeedListener( + navFragment: Fragment = this, + afterOpen: () -> Unit = {} + ): FeedClickListener { + val key = arguments?.getString("feedListener") + return when (key) { + "playlist_search" -> EditPlaylistSearchClickListener(this) + else -> FeedClickListener( + this, + navFragment.parentFragmentManager, + navFragment.id, + afterOpen + ) + } + } + } + + open fun onTabSelected( + view: View?, + feedId: String?, + extensionId: String?, + position: Int + ): Boolean { + val vm by fragment.viewModel() + val feedData = vm.feedDataMap[feedId] ?: return notFoundSnack(R.string.feed) + feedData.selectTab(extensionId, position) + return true + } + + open fun onSortClicked(view: View?, feedId: String?): Boolean { + val vm by fragment.viewModel() + val feedData = vm.feedDataMap[feedId] ?: return notFoundSnack(R.string.feed) + feedData.feedSortState.value = feedData.feedSortState.value ?: FeedSort.State() + FeedSortBottomSheet.newInstance(feedId!!).show(fragment.childFragmentManager, null) + return true + } + + open fun onPlayClicked( + view: View?, + extensionId: String?, + context: EchoMediaItem?, + tracks: List?, + shuffle: Boolean + ): Boolean { + if (extensionId == null) return notFoundSnack(R.string.extension) + val vm by fragment.activityViewModels() + if (tracks != null) { + if (tracks.isEmpty()) return notFoundSnack(R.string.tracks) + vm.setQueue(extensionId, tracks, 0, context) + vm.setShuffle(shuffle, true) + vm.setPlaying(true) + return true + } + if (context == null) return notFoundSnack(R.string.item) + if (shuffle) vm.shuffle(extensionId, context, true) + else vm.play(extensionId, context, true) + return true + } + + open fun openFeed( + view: View?, + extensionId: String?, + feedId: String?, + title: String?, + subtitle: String?, + feed: Feed? + ): Boolean { + val fragment = fragmentManager.findFragmentById(containerId) + ?: return notFoundSnack(R.string.view) + val vm by fragment.activityViewModels() + vm.extensionId = extensionId ?: return notFoundSnack(R.string.extension) + vm.feedId = feedId ?: return notFoundSnack(R.string.item) + vm.feed = feed ?: return notFoundSnack(R.string.feed) + fragment.openFragment(view, FeedFragment.getBundle(title.orEmpty(), subtitle)) + afterOpen() + return true + } + + fun notFoundSnack(id: Int): Boolean = with(fragment) { + val notFound = getString(R.string.no_x_found, getString(id)) + createSnack(notFound) + false + } + + open fun onMediaClicked( + view: View?, extensionId: String?, item: EchoMediaItem?, context: EchoMediaItem? + ): Boolean { + if (extensionId == null) return notFoundSnack(R.string.extension) + if (item == null) return notFoundSnack(R.string.item) + return when (item) { + is Radio -> { + val vm by fragment.activityViewModels() + vm.play(extensionId, item, false) + true + } + + else -> { + val fragment = fragmentManager.findFragmentById(containerId) + ?: return notFoundSnack(R.string.view) + fragment.openFragment(view, getBundle(extensionId, item, false)) + afterOpen() + true + } + } + } + + open fun onMediaLongClicked( + view: View?, extensionId: String?, item: EchoMediaItem?, context: EchoMediaItem?, + tabId: String?, index: Int + ): Boolean { + if (extensionId == null) return notFoundSnack(R.string.extension) + if (item == null) return notFoundSnack(R.string.item) + MediaMoreBottomSheet.newInstance( + containerId, extensionId, item, false, + context = context, tabId = tabId, pos = index + ).show(fragmentManager, null) + return true + } + + open fun onTracksClicked( + view: View?, + extensionId: String?, + context: EchoMediaItem?, + tracks: List?, + pos: Int + ): Boolean { + if (extensionId == null) return notFoundSnack(R.string.extension) + if (tracks.isNullOrEmpty()) return notFoundSnack(R.string.tracks) + val vm by fragment.activityViewModels() + vm.setQueue(extensionId, tracks, pos, context) + vm.setPlaying(true) + return true + } + + open fun onTrackSwiped( + view: View?, extensionId: String?, track: Track?, + ): Boolean { + if (extensionId == null) return notFoundSnack(R.string.extension) + if (track == null) return notFoundSnack(R.string.track) + val vm by fragment.activityViewModels() + vm.addToNext(extensionId, track, false) + return true + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedData.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedData.kt new file mode 100644 index 0000000..8224a6b --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedData.kt @@ -0,0 +1,294 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.os.Parcelable +import androidx.paging.cachedIn +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.builtin.offline.MediaStoreUtils.searchBy +import dev.brahmkshatriya.echo.ui.common.PagedSource +import dev.brahmkshatriya.echo.ui.feed.FeedType.Companion.toFeedType +import dev.brahmkshatriya.echo.ui.feed.viewholders.HorizontalListViewHolder +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import dev.brahmkshatriya.echo.utils.CoroutineUtils.combineTransformLatest +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadDrawable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.lang.ref.WeakReference + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +data class FeedData( + private val feedId: String, + private val scope: CoroutineScope, + private val app: App, + private val extensionLoader: ExtensionLoader, + private val cached: suspend ExtensionLoader.() -> State>?, + private val load: suspend ExtensionLoader.() -> State>?, + private val defaultButtons: Feed.Buttons, + private val noVideos: Boolean, + private val extraLoadFlow: Flow<*> +) { + val current = extensionLoader.current + val usersFlow = extensionLoader.db.currentUsersFlow + suspend fun getExtension(id: String) = + extensionLoader.getFlow(ExtensionType.MUSIC).getExtensionOrThrow(id) + + val layoutManagerStates = hashMapOf() + val visibleScrollableViews = hashMapOf>() + + private val refreshFlow = MutableSharedFlow(1) + private val cachedState = MutableStateFlow>?>?>(null) + private val loadedState = MutableStateFlow>?>?>(null) + private val selectedTabFlow = MutableStateFlow(null) + + val loadedShelves = MutableStateFlow?>(null) + var searchToggled: Boolean = false + var searchQuery: String? = null + val feedSortState = MutableStateFlow(null) + val searchClickedFlow = MutableSharedFlow() + + private val stateFlow = cachedState.combine(loadedState) { a, b -> a to b } + .stateIn(scope, Lazily, null to null) + + private val cachedDataFlow = cachedState.combineTransformLatest(selectedTabFlow) { feed, tab -> + emit(null) + if (feed == null) return@combineTransformLatest + emit(getData(feed, tab)) + }.stateIn(scope, Lazily, null) + + private val loadedDataFlow = loadedState.combineTransformLatest(selectedTabFlow) { feed, tab -> + emit(null) + if (feed == null) return@combineTransformLatest + emit(getData(feed, tab)) + }.stateIn(scope, Lazily, null) + + private suspend fun getData( + state: Result>?>, tab: Tab? + ) = withContext(Dispatchers.IO) { + runCatching { + val (extensionId, item, feed) = state.getOrThrow() ?: return@runCatching null + State(extensionId, item, feed.getPagedData(tab)) + } + } + + val dataFlow = cachedDataFlow.combine(loadedDataFlow) { cached, loaded -> + val extensionId = (loaded?.getOrNull() ?: cached?.getOrNull())?.extensionId + val tabId = selectedTabFlow.value?.id + searchQuery = null + searchToggled = false + val id = "$extensionId-$feedId-$tabId" + feedSortState.value = extensionId?.let { app.context.getFromCache(id, "sort") } + loadedShelves.value = null + cached to loaded + } + + val shouldShowEmpty = dataFlow.map { (cached, loaded) -> + val data = loaded?.getOrNull() ?: cached?.getOrNull() + data != null + }.stateIn(scope, Lazily, false) + + val tabsFlow = stateFlow.map { (cached, loaded) -> + val state = (loaded?.getOrNull() ?: cached?.getOrNull()) ?: return@map listOf() + state.feed.tabs.map { + FeedTab(feedId, state.extensionId, it) + } + } + + val selectedTabIndexFlow = tabsFlow.combine(selectedTabFlow) { tabs, tab -> + tabs.indexOfFirst { it.tab.id == tab?.id } + } + + data class FeedTab( + val feedId: String, + val extensionId: String, + val tab: Tab + ) + + data class Buttons( + val feedId: String, + val extensionId: String, + val buttons: Feed.Buttons, + val item: EchoMediaItem? = null, + val sortState: FeedSort.State? = null, + ) + + val buttonsFlow = dataFlow.combine(feedSortState) { data, state -> + val feed = data.run { second?.getOrNull() ?: first?.getOrNull() } ?: return@combine null + Buttons( + feedId, + feed.extensionId, + feed.feed.buttons ?: defaultButtons, + feed.item, + state, + ) + } + + private val imageFlow = dataFlow.map { (cached, loaded) -> + (loaded?.getOrNull() ?: cached?.getOrNull())?.feed?.background + }.stateIn(scope, Lazily, null) + + val backgroundImageFlow = imageFlow.mapLatest { image -> + image?.loadDrawable(app.context) + }.flowOn(Dispatchers.IO).stateIn(scope, Lazily, null) + + val cachedFeedTypeFlow = + combineTransformLatest(cachedDataFlow, feedSortState, searchClickedFlow) { _ -> + emit(null) + val cached = cachedDataFlow.value ?: return@combineTransformLatest + emit(getFeedSourceData(cached)) + }.stateIn(scope, Lazily, null) + + val loadedFeedTypeFlow = + combineTransformLatest(loadedDataFlow, feedSortState, searchClickedFlow) { _ -> + emit(null) + val loaded = loadedDataFlow.value ?: return@combineTransformLatest + emit(getFeedSourceData(loaded)) + }.stateIn(scope, Lazily, null) + + val pagingFlow = + cachedFeedTypeFlow.combineTransformLatest(loadedFeedTypeFlow) { cached, loaded -> + emitAll(PagedSource(loaded, cached).flow) + }.cachedIn(scope) + + private suspend fun getFeedSourceData( + result: Result>?> + ): Result> = withContext(Dispatchers.IO) { + val tabId = selectedTabFlow.value?.id + val data = if (feedSortState.value != null || searchQuery != null) { + result.mapCatching { state -> + state ?: return@mapCatching PagedData.empty() + val extensionId = state.extensionId + val data = state.feed.pagedData + + val sortState = feedSortState.value + val query = searchQuery + var shelves = data.loadTill(2000) + shelves = if (sortState?.feedSort != null || query != null) + shelves.flatMap { shelf -> + when (shelf) { + is Shelf.Category -> listOf(shelf) + is Shelf.Item -> listOf(shelf) + is Shelf.Lists.Categories -> shelf.list + is Shelf.Lists.Items -> shelf.list.map { it.toShelf() } + is Shelf.Lists.Tracks -> shelf.list.map { it.toShelf() } + } + } + else shelves + loadedShelves.value = shelves + if (sortState != null) { + shelves = sortState.feedSort?.sorter?.invoke(app.context, shelves) ?: shelves + if (sortState.reversed) shelves = shelves.reversed() + if (sortState.save) + app.context.saveToCache("$extensionId-$feedId-$tabId", sortState, "sort") + } + if (query != null) { + shelves = shelves.searchBy(query) { + listOf(it.title) + }.map { it.second } + } + PagedData.Single { + shelves.toFeedType( + feedId, + extensionId, + state.item, + tabId, + noVideos + ) + } + } + } else result.mapCatching { state -> + state ?: return@mapCatching PagedData.empty() + val extId = state.extensionId + val data = state.feed.pagedData + data.loadPage(null) + var start = 0L + data.map { result -> + result.map { + val list = it.toFeedType(feedId, extId, state.item, tabId, noVideos, start) + start += list.size + list + }.getOrThrow() + } + } + data + } + + private suspend fun PagedData.loadTill(limit: Long): List { + val list = mutableListOf() + var page = loadPage(null) + list.addAll(page.data) + while (page.continuation != null && list.size < limit) { + page = loadPage(page.continuation) + list.addAll(page.data) + } + return list + } + + val isRefreshingFlow = loadedFeedTypeFlow.map { + loadedFeedTypeFlow.value == null + }.stateIn(scope, Lazily, true) + + fun selectTab(extensionId: String?, pos: Int) { + val state = stateFlow.value.run { second?.getOrNull() ?: first?.getOrNull() } + val tab = state?.feed?.tabs?.getOrNull(pos) + ?.takeIf { state.extensionId == extensionId } + app.context.saveToCache(feedId, tab?.id, "selected_tab") + selectedTabFlow.value = tab + } + + fun refresh() = scope.launch { refreshFlow.emit(Unit) } + + init { + scope.launch(Dispatchers.IO) { + listOfNotNull(current, refreshFlow, usersFlow, extraLoadFlow) + .merge().debounce(100L).collectLatest { + cachedState.value = null + loadedState.value = null + extensionLoader.current.value ?: return@collectLatest + cachedState.value = runCatching { cached(extensionLoader) } + loadedState.value = runCatching { load(extensionLoader) } + } + } + scope.launch { + stateFlow.collect { result -> + val feed = result.run { second?.getOrNull() ?: first?.getOrNull() }?.feed?.tabs + selectedTabFlow.value = if (feed == null) null else { + val last = app.context.getFromCache(feedId, "selected_tab") + feed.find { it.id == last } ?: feed.firstOrNull() + } + } + } + } + + data class State( + val extensionId: String, + val item: EchoMediaItem?, + val feed: T, + ) + + fun onSearchClicked() = scope.launch { searchClickedFlow.emit(Unit) } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedFragment.kt new file mode 100644 index 0000000..726dc10 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedFragment.kt @@ -0,0 +1,122 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import androidx.fragment.app.replace +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.databinding.FragmentGenericCollapsableBinding +import dev.brahmkshatriya.echo.databinding.FragmentRecyclerWithRefreshBinding +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.extensions.cache.Cached.savingFeed +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.configure +import dev.brahmkshatriya.echo.ui.extensions.login.LoginFragment.Companion.bind +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.main.MainFragment.Companion.applyPlayerBg +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.viewModel + +class FeedFragment : Fragment(R.layout.fragment_generic_collapsable) { + companion object { + fun getBundle(title: String, subtitle: String?) = Bundle().apply { + putString("title", title) + putString("subtitle", subtitle) + } + } + + class VM : ViewModel() { + var initialized = false + var extensionId: String? = null + var feedId: String? = null + var feed: Feed? = null + } + + private val activityVm by activityViewModels() + private val vm by viewModels() + + private val feedData by lazy { + val feedViewModel by viewModel() + if (!vm.initialized) { + vm.initialized = true + vm.extensionId = activityVm.extensionId + vm.feedId = activityVm.feedId + vm.feed = activityVm.feed + } + feedViewModel.getFeedData( + vm.feedId ?: "", + cached = { + val extId = vm.extensionId!! + val feed = Cached.getFeedShelf(app, extId, vm.feedId!!) + FeedData.State(extId, null, feed.getOrThrow()) + } + ) { + val extension = music.getExtensionOrThrow(vm.extensionId) + val feed = savingFeed(app, extension, vm.feedId!!, vm.feed!!) + FeedData.State(extension.id, null, feed) + } + } + + private val title by lazy { arguments?.getString("title")!! } + private val subtitle by lazy { arguments?.getString("subtitle") } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentGenericCollapsableBinding.bind(view) + binding.bind(this, false) + binding.extensionIcon.isVisible = false + binding.toolBar.title = title + binding.toolBar.subtitle = subtitle + applyPlayerBg(view) { + mainBgDrawable.combine(feedData.backgroundImageFlow) { a, b -> b ?: a } + } + if (savedInstanceState == null) childFragmentManager.commit { + replace(R.id.genericFragmentContainer, null, arguments) + } + } + + class Actual() : Fragment(R.layout.fragment_recycler_with_refresh) { + private val feedData by lazy { + val vm by requireParentFragment().viewModel() + vm.feedDataMap.values.first() + } + + private val listener by lazy { requireParentFragment().getFeedListener() } + private val feedAdapter by lazy { + getFeedAdapter(feedData, listener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentRecyclerWithRefreshBinding.bind(view) + applyInsets { + binding.recyclerView.applyContentInsets(it, 20, 8, 16) + } + FastScrollerHelper.applyTo(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + feedAdapter.withLoading(this) + ) + getTouchHelper(listener).attachToRecyclerView(binding.recyclerView) + binding.swipeRefresh.run { + configure() + setOnRefreshListener { feedData.refresh() } + observe(feedData.isRefreshingFlow) { + isRefreshing = it + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedLoadingAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedLoadingAdapter.kt new file mode 100644 index 0000000..36b0207 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedLoadingAdapter.kt @@ -0,0 +1,143 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import dev.brahmkshatriya.echo.databinding.ItemShelfErrorBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfLoginRequiredBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfNotLoadingBinding +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.extensions.exceptions.AppException +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.getFinalTitle +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.getMessage +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.openLoginException +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.PagedSource +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimLoadStateAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class FeedLoadingAdapter( + val listener: Listener? = null, + val loadingAdapter: (ViewGroup) -> ViewHolder, +) : ScrollAnimLoadStateAdapter(), GridAdapter { + + interface Listener { + fun onRetry() + fun onError(view: View, error: Throwable) + fun onLoginRequired(view: View, error: AppException.LoginRequired) + } + + abstract class ViewHolder(val view: View) : ScrollAnimViewHolder(view) { + open fun bind(loadState: LoadState) {} + } + + data class NotLoading( + val inflater: LayoutInflater, + val parent: ViewGroup, + val listener: Listener?, + val binding: ItemShelfNotLoadingBinding = + ItemShelfNotLoadingBinding.inflate(inflater, parent, false) + ) : ViewHolder(binding.root) { + override fun bind(loadState: LoadState) { + binding.retry.setOnClickListener { + listener?.onRetry() + } + } + } + + data class Error( + val inflater: LayoutInflater, + val parent: ViewGroup, + val listener: Listener?, + val binding: ItemShelfErrorBinding = + ItemShelfErrorBinding.inflate(inflater, parent, false) + ) : ViewHolder(binding.root) { + override fun bind(loadState: LoadState) { + loadState as LoadState.Error + val throwable = loadState.error + binding.error.run { + transitionName = throwable.hashCode().toString() + text = context.getFinalTitle(throwable) + } + binding.errorView.setOnClickListener { + listener?.onError(binding.error, throwable) + } + binding.retry.setOnClickListener { + listener?.onRetry() + } + } + } + + data class LoginRequired( + val inflater: LayoutInflater, + val parent: ViewGroup, + val listener: Listener?, + val binding: ItemShelfLoginRequiredBinding + = ItemShelfLoginRequiredBinding.inflate(inflater, parent, false) + ) : ViewHolder(binding.root) { + override fun bind(loadState: LoadState) { + val error = (loadState as LoadState.Error).error + val appError = error as AppException.LoginRequired + binding.error.run { + text = context.getFinalTitle(appError) + } + binding.login.transitionName = appError.hashCode().toString() + binding.login.setOnClickListener { + listener?.onLoginRequired(it, appError) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (getStateViewType(loadState)) { + 0 -> loadingAdapter(parent) + 1 -> NotLoading(inflater, parent, listener) + 2 -> Error(inflater, parent, listener) + 3 -> LoginRequired(inflater, parent, listener) + else -> throw IllegalStateException() + } + } + + override fun getStateViewType(loadState: LoadState): Int { + return when (loadState) { + is LoadState.Loading -> 0 + is LoadState.NotLoading -> 1 + is LoadState.Error -> { + when (loadState.error) { + is AppException.LoginRequired -> 3 + is PagedSource.LoadingException -> 0 + is Cached.NotFound -> 1 + else -> 2 + } + } + } + } + + override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) { + super.onBindViewHolder(holder, loadState) + holder.bind(loadState) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + + companion object { + fun Fragment.createListener(retry: () -> Unit) = + object : Listener { + override fun onRetry() { + retry() + } + + override fun onError(view: View, error: Throwable) { + requireActivity().getMessage(error, view).action?.handler?.invoke() + } + + override fun onLoginRequired(view: View, error: AppException.LoginRequired) { + requireActivity().openLoginException(error, view) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSort.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSort.kt new file mode 100644 index 0000000..7daf635 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSort.kt @@ -0,0 +1,172 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.content.Context +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.feed.FeedSort.entries +import dev.brahmkshatriya.echo.ui.media.MediaHeaderAdapter.Companion.toTrackString +import kotlinx.serialization.Serializable + +fun getSorts(data: List): List { + return entries.filter { it.shouldBeVisible(data) != null } +} + +private fun Shelf.title() = when (this) { + is Shelf.Category -> this.title + is Shelf.Item -> this.media.title + is Shelf.Lists<*> -> null +} + +private fun Shelf.subtitle() = when (this) { + is Shelf.Category -> this.subtitle + is Shelf.Item -> this.media.subtitle + is Shelf.Lists<*> -> null +} + +private fun Shelf.duration() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is Track -> item.duration + is Album -> item.duration + is Playlist -> item.duration + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.date() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is Track -> item.releaseDate + is Album -> item.releaseDate + is Playlist -> item.creationDate + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.dateAdded() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is Track -> item.playlistAddedDate + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.artists() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is Track -> item.artists + is EchoMediaItem.Lists -> item.artists + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.album() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is Track -> item.album + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.trackCount() = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (val item = this.media) { + is EchoMediaItem.Lists -> item.trackCount + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.ifListItem(): EchoMediaItem.Lists? = when (this) { + is Shelf.Category -> null + is Shelf.Item -> when (media) { + is EchoMediaItem.Lists -> media as EchoMediaItem.Lists + else -> null + } + + is Shelf.Lists<*> -> null +} + +private fun Shelf.copy(subtitle: String) = when (this) { + is Shelf.Category -> this.copy(subtitle = subtitle) + is Shelf.Item -> this.copy(media = this.media.copyMediaItem(subtitle = subtitle)) + is Shelf.Lists<*> -> throw IllegalStateException() +} + +enum class FeedSort( + val title: Int, + val sorter: Context.(List) -> List +) { + Title(R.string.sort_title, { list -> list.sortedBy { it.title() } }), + Subtitle(R.string.sort_subtitle, { list -> list.sortedBy { it.subtitle() } }), + Duration(R.string.sort_duration, { list -> + list.sortedBy { it.duration() } + }), + Date(R.string.sort_date, { list -> + list.sortedBy { it.date() } + .filter { it.date() != null } + .map { it.copy(subtitle = it.date().toString()) } + }), + DateAdded(R.string.sort_date_added, { list -> + list.sortedBy { it.date() } + .filter { it.date() != null } + .map { it.copy(subtitle = it.date().toString()) } + }), + Artists(R.string.artists, { list -> + list.flatMap { shelf -> + shelf.artists().orEmpty().map { + it to shelf.copy(subtitle = it.name) + } + }.sortedBy { it.first.name.lowercase() }.groupBy { it.first.id } + .values.flatMap { it -> it.map { it.second } } + }), + Album(R.string.albums, { list -> + list.flatMap { shelf -> + shelf.album()?.let { album -> + listOf(shelf.copy(subtitle = album.title)) + } ?: emptyList() + }.sortedBy { it.subtitle() } + }), + Tracks(R.string.tracks, { list -> + list.sortedBy { it.trackCount() } + .filter { it.trackCount() != null } + .map { it.copy(subtitle = it.ifListItem()?.toTrackString(this) ?: "???") } + }) + ; + + fun shouldBeVisible(data: List): FeedSort? { + val take = when (this) { + Title -> data.any { it.title()?.isNotEmpty() ?: false } + Subtitle -> data.any { it.subtitle()?.isNotEmpty() ?: false } + Date -> data.any { it.date() != null } + DateAdded -> data.any { it.dateAdded() != null } + Duration -> data.any { it.duration() != null } + Artists -> data.any { it.artists()?.isNotEmpty() ?: false } + Album -> data.mapNotNull { it.album() }.toSet().size > 1 + Tracks -> data.any { it.trackCount() != null } + } + return if (take) this else null + } + + @Serializable + data class State( + val feedSort: FeedSort? = null, + val reversed: Boolean = false, + val save: Boolean = false, + ) +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSortBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSortBottomSheet.kt new file mode 100644 index 0000000..4ed03d9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedSortBottomSheet.kt @@ -0,0 +1,108 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.content.DialogInterface +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip +import dev.brahmkshatriya.echo.databinding.DialogSortBinding +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.viewModel + +class FeedSortBottomSheet : BottomSheetDialogFragment() { + + private val feedData by lazy { + val vm by requireParentFragment().viewModel() + val id = requireArguments().getString("id")!! + vm.feedDataMap[id]!! + } + private val loadedShelves by lazy { feedData.loadedShelves } + private val sortState by lazy { feedData.feedSortState } + + private var binding by autoCleared() + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = DialogSortBinding.inflate(inflater, container, false) + return binding.root + } + + private var selectedFeedSort: FeedSort? = null + private fun applySorts() { + val list = loadedShelves.value ?: listOf() + val state = sortState.value + selectedFeedSort = state?.feedSort + val available = getSorts(list) + val checked = available.indexOf(state?.feedSort) + binding.sortChipGroup.run { + removeAllViews() + available.forEachIndexed { index, t -> + val chip = Chip(context) + chip.id = index + chip.text = getString(t.title) + chip.ellipsize = TextUtils.TruncateAt.MIDDLE + chip.isCheckable = true + addView(chip) + if (index == checked) check(chip.id) + } + setOnCheckedStateChangeListener { _, checkedIds -> + selectedFeedSort = available.getOrNull(checkedIds.firstOrNull() ?: -1) + } + } + binding.filter.isVisible = false + binding.filterGroup.isVisible = false + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + observe(loadedShelves) { + binding.progressIndicator.isVisible = it == null + applySorts() + } + observe(sortState) { + applySorts() + binding.saveCheckbox.isChecked = it?.save ?: false + binding.reversedSwitch.isChecked = it?.reversed ?: false + } + binding.saveContainer.setOnClickListener { + binding.saveCheckbox.isChecked = !binding.saveCheckbox.isChecked + } + binding.reversedContainer.setOnClickListener { + binding.reversedSwitch.isChecked = !binding.reversedSwitch.isChecked + } + binding.apply.setOnClickListener { + sortState.value = FeedSort.State( + feedSort = selectedFeedSort, + reversed = binding.reversedSwitch.isChecked, + save = binding.saveCheckbox.isChecked + ) + dismiss() + } + binding.topAppBar.setNavigationOnClickListener { + dismiss() + } + binding.topAppBar.setOnMenuItemClickListener { + sortState.value = FeedSort.State() + dismiss() + true + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + parentFragmentManager.setFragmentResult("shelfSort", Bundle().apply { + putBoolean("changed", true) + }) + } + + companion object { + fun newInstance(id: String) = FeedSortBottomSheet().apply { + arguments = bundleOf("id" to id) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedType.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedType.kt new file mode 100644 index 0000000..d768d98 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedType.kt @@ -0,0 +1,180 @@ +package dev.brahmkshatriya.echo.ui.feed + +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonClassDiscriminator + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("clazzType") +@Serializable +sealed interface FeedType { + + enum class Enum { + Header, HorizontalList, + Category, CategoryGrid, + Media, MediaGrid, + Video, VideoHorizontal, + } + + val feedId: String + val id: String + val type: Enum + val extId: String + val extras: Map? + + val extensionId: String + get() = extras?.let { + if (it["cached"] == "true") it[EXTENSION_ID] else null + } ?: extId + + val context: EchoMediaItem? + val tabId: String? + + @Serializable + data class Header( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + override val id: String, + val title: String, + val subtitle: String? = null, + @Transient val more: Feed? = null, + val tracks: List? = null, + ) : FeedType { + override val type = Enum.Header + override val extras: Map? = null + } + + @Serializable + data class Category( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val shelf: Shelf.Category, + override val type: Enum = Enum.Category, + ) : FeedType { + override val id = shelf.id + override val extras: Map? = shelf.extras + } + + @Serializable + data class Media( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val item: EchoMediaItem, + val number: Long?, + ) : FeedType { + override val id = item.id + override val type: Enum = Enum.Media + override val extras: Map? = item.extras + } + + @Serializable + data class Video( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val item: Track, + override val type: Enum = Enum.Video, + ) : FeedType { + override val id = item.id + override val extras: Map? = item.extras + } + + @Serializable + data class MediaGrid( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val item: EchoMediaItem, + val number: Int? = null, + ) : FeedType { + override val id = item.id + override val type: Enum = Enum.MediaGrid + override val extras: Map? = item.extras + } + + @Serializable + data class HorizontalList( + override val feedId: String, + override val extId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val shelf: Shelf.Lists<*>, + ) : FeedType { + override val id = shelf.id + override val type = Enum.HorizontalList + override val extras: Map? = shelf.extras + } + + companion object { + fun List.toFeedType( + feedId: String, + extId: String, + context: EchoMediaItem?, + tabId: String?, + noVideos: Boolean = false, + start: Long = 0, + ): List = mapIndexed { index, shelf -> + + when (shelf) { + is Shelf.Category -> if (shelf.feed == null) listOf( + Header( + feedId, extId, context, tabId, shelf.id, shelf.title, shelf.subtitle, + ) + ) else listOf(Category(feedId, extId, context, tabId, shelf)) + + is Shelf.Item -> when (val item = shelf.media) { + is Track -> if (!noVideos) when (item.type) { + Track.Type.Video -> listOf(Video(feedId, extId, context, tabId, item)) + Track.Type.HorizontalVideo -> listOf( + Video(feedId, extId, context, tabId, item, Enum.VideoHorizontal) + ) + + else -> listOf(Media(feedId, extId, context, tabId, item, null)) + } else { + val index = start + index + listOf(Media(feedId, extId, context, tabId, item, index)) + } + + else -> listOf(Media(feedId, extId, context, tabId, item, null)) + } + + is Shelf.Lists<*> -> listOf( + Header( + feedId, extId, context, tabId, + shelf.id, shelf.title, shelf.subtitle, shelf.more, + if (shelf is Shelf.Lists.Tracks) shelf.list else null + ) + ) + if (shelf.type == Shelf.Lists.Type.Linear) listOf( + HorizontalList(feedId, extId, context, tabId, shelf) + ) + else when (shelf) { + is Shelf.Lists.Categories -> shelf.list.map { + Category(feedId, extId, context, tabId, it, Enum.CategoryGrid) + } + + is Shelf.Lists.Items -> shelf.list.map { + MediaGrid(feedId, extId, context, tabId, it) + } + + is Shelf.Lists.Tracks -> shelf.list.mapIndexed { index, item -> + MediaGrid(feedId, extId, context, tabId, item, index + 1) + } + } + } + }.flatten() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedViewModel.kt new file mode 100644 index 0000000..234bcd4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/FeedViewModel.kt @@ -0,0 +1,39 @@ +package dev.brahmkshatriya.echo.ui.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge + +class FeedViewModel( + val app: App, + private val extensionLoader: ExtensionLoader, +) : ViewModel() { + val feedDataMap = hashMapOf() + fun getFeedData( + id: String, + buttons: Feed.Buttons = Feed.Buttons(), + noVideos: Boolean = false, + vararg extraLoadFlow: Flow<*>, + cached: suspend ExtensionLoader.() -> FeedData.State>? = { null }, + loader: suspend ExtensionLoader.() -> FeedData.State>? + ): FeedData { + return feedDataMap.getOrPut(id) { + FeedData( + feedId = id, + scope = viewModelScope, + app = app, + extensionLoader = extensionLoader, + cached = cached, + load = loader, + defaultButtons = buttons, + noVideos = noVideos, + extraLoadFlow = extraLoadFlow.toList().merge(), + ) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/TabsAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/TabsAdapter.kt new file mode 100644 index 0000000..a97979f --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/TabsAdapter.kt @@ -0,0 +1,90 @@ +package dev.brahmkshatriya.echo.ui.feed + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonGroup +import dev.brahmkshatriya.echo.databinding.ItemTabBinding +import dev.brahmkshatriya.echo.databinding.ItemTabContainerBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class TabsAdapter( + private val getTitle: T.() -> String, + private val onTabSelected: (View, Int, T) -> Unit +) : ScrollAnimRecyclerAdapter(), GridAdapter { + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent) + override fun getItemCount() = 1 + + var data: List = emptyList() + set(value) { + field = value + notifyItemChanged(0) + } + + var selected = -1 + set(value) { + field = value + parent?.let { apply(it) } + } + + var parent: MaterialButtonGroup? = null + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val parent = holder.binding.buttonGroup + this.parent = parent + apply(parent) + parent.doOnLayout { + if (selected < 0) return@doOnLayout + val scrollX = parent.children.filter { it.isVisible } + .take(selected).sumOf { it.width } + holder.binding.root.scrollTo(scrollX, 0) + } + } + + fun apply(parent: MaterialButtonGroup) { + val tabs = data + parent.isVisible = tabs.isNotEmpty() + if (tabs.isEmpty()) return + val toKeep = tabs.size - parent.childCount + val inflater = LayoutInflater.from(parent.context) + if (toKeep > 0) repeat(toKeep) { + parent.addView(ItemTabBinding.inflate(inflater, parent, false).root) + } else if (toKeep < 0) repeat(-toKeep) { + parent.getChildAt(tabs.size + it).isVisible = false + } + tabs.indices.forEach { i -> + val tab = tabs[i] + val button = parent.getChildAt(i) as MaterialButton + button.apply { + isVisible = true + val title = getTitle(tab) + if (text.toString() != title) text = title + setOnClickListener(null) + isChecked = i == selected + setOnClickListener { + if (i == selected) isChecked = true + else onTabSelected(it, i, tab) + } + } + } + } + + class ViewHolder( + parent: ViewGroup, + val binding: ItemTabContainerBinding = ItemTabContainerBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + init { + binding.buttonGroup.removeAllViews() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/CategoryViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/CategoryViewHolder.kt new file mode 100644 index 0000000..0a9b664 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/CategoryViewHolder.kt @@ -0,0 +1,78 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.graphics.Color +import android.graphics.Color.HSVToColor +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.cardview.widget.CardView +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.toColorInt +import androidx.core.view.isVisible +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isNightMode +import kotlin.math.roundToInt + +class CategoryViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + private val binding: ItemShelfCategoryBinding = ItemShelfCategoryBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + + private var feed: FeedType.Category? = null + + init { + binding.root.setOnClickListener { + listener.openFeed( + it, + feed?.extensionId, + feed?.id, + feed?.shelf?.title, + feed?.shelf?.subtitle, + feed?.shelf?.feed + ) + } + } + + override fun bind(feed: FeedType.Category) { + this.feed = feed + val category = feed.shelf + binding.bind(category) + } + + companion object { + fun ItemShelfCategoryBinding.bind(category: Shelf.Category) { + title.text = category.title + subtitle.text = category.subtitle + subtitle.isVisible = !category.subtitle.isNullOrEmpty() + icon.isVisible = category.image != null + category.image.loadInto(icon) + root.run { + val color = applyBackground(category.backgroundColor) + ?: ResourcesCompat.getColor(resources, R.color.amoled_fg_semi, null) + setCardBackgroundColor(color) + } + } + + fun CardView.applyBackground(hex: String?): Int? { + val hsv = runCatching { hex?.toColorInt() }.getOrNull()?.run { + val hsv = FloatArray(3) + Color.colorToHSV(this, hsv) + hsv + } ?: return null + val actualSat = (hsv[1] * 0.25).roundToInt() + val sat = if (context.isNightMode()) (35f + actualSat) / 100 else 0.2f + val value = if (context.isNightMode()) 0.5f else 0.9f + val color = HSVToColor(floatArrayOf(hsv[0], sat, value)) + val with = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary) + return MaterialColors.harmonize(color, with) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/FeedViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/FeedViewHolder.kt new file mode 100644 index 0000000..f22d225 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/FeedViewHolder.kt @@ -0,0 +1,13 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.view.View +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +sealed class FeedViewHolder(view: View) : ScrollAnimViewHolder(view) { + abstract fun bind(feed: T) + open fun canBeSwiped(): Boolean = false + open fun onSwipe(): T? = null + open fun onCurrentChanged(current: PlayerState.Current?) {} +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HeaderViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HeaderViewHolder.kt new file mode 100644 index 0000000..3d34993 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HeaderViewHolder.kt @@ -0,0 +1,45 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.databinding.ItemShelfHeaderBinding +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType + +class HeaderViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + private val binding: ItemShelfHeaderBinding = ItemShelfHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + + private var feed: FeedType.Header? = null + + init { + binding.more.setOnClickListener { + listener.openFeed( + it, + feed?.extensionId, + feed?.id, + feed?.title, + feed?.subtitle, + feed?.more + ) + } + binding.shuffle.setOnClickListener { + listener.onPlayClicked(it, feed?.extensionId, feed?.context, feed?.tracks, true) + } + } + + override fun bind(feed: FeedType.Header) { + this.feed = feed + binding.title.text = feed.title + binding.title.isVisible = feed.title.isNotEmpty() + binding.subtitle.text = feed.subtitle + binding.subtitle.isVisible = !feed.subtitle.isNullOrEmpty() + binding.shuffle.isVisible = feed.tracks != null + binding.more.isVisible = feed.more != null + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HorizontalListViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HorizontalListViewHolder.kt new file mode 100644 index 0000000..2551a47 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/HorizontalListViewHolder.kt @@ -0,0 +1,162 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.updatePaddingRelative +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfListsBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.ui.feed.viewholders.shelf.ShelfType +import dev.brahmkshatriya.echo.ui.feed.viewholders.shelf.ShelfViewHolder +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationAndScaleAnimation +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx + +class HorizontalListViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + pool: RecyclerView.RecycledViewPool, + private val binding: ItemShelfListsBinding = ItemShelfListsBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + val adapter = Adapter(listener) + val layoutManager = LinearLayoutManager(parent.context, RecyclerView.HORIZONTAL, false) + + init { + binding.root.setRecycledViewPool(pool) + binding.root.layoutManager = layoutManager + } + + override fun bind(feed: FeedType.HorizontalList) { + val endPadding = if (feed.shelf is Shelf.Lists.Tracks) 8 else 20 + binding.root.updatePaddingRelative(end = endPadding.dpToPx(binding.root.context)) + adapter.tracks = feed.shelf.list.filterIsInstance() + adapter.submitList(feed.shelf.toShelfType(feed.extensionId, feed.context, feed.tabId)) { + binding.root.adapter = adapter + } + } + + override fun onCurrentChanged(current: PlayerState.Current?) { + adapter.onCurrentChanged(current) + } + + fun Shelf.Lists<*>.toShelfType( + extensionId: String, context: EchoMediaItem?, tabId: String? + ) = when (this) { + is Shelf.Lists.Items -> list.map { ShelfType.Media(extensionId, context, tabId, it) } + is Shelf.Lists.Categories -> list.map { + ShelfType.Category(extensionId, context, tabId, it) + } + + is Shelf.Lists.Tracks -> list.chunked(3).mapIndexed { index, it -> + ShelfType.ThreeTracks( + extensionId, context, tabId, index, + Triple( + it[0], + it.getOrNull(1), + it.getOrNull(2) + ) + ) + } + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ShelfType, newItem: ShelfType): Boolean { + if (oldItem.extensionId != newItem.extensionId) return false + if (oldItem.type != newItem.type) return false + if (oldItem.id != newItem.id) return false + return true + } + + override fun areContentsTheSame(oldItem: ShelfType, newItem: ShelfType): Boolean { + return oldItem == newItem + } + } + + class Adapter( + private val listener: FeedClickListener + ) : ListAdapter>(DiffCallback) { + var tracks: List = emptyList() + override fun getItemViewType(position: Int) = currentList[position].type.ordinal + override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int + ) = when (ShelfType.Enum.entries[viewType]) { + ShelfType.Enum.Category -> ShelfViewHolder.Category(parent, listener) + ShelfType.Enum.Media -> ShelfViewHolder.Media(parent, listener) + ShelfType.Enum.ThreeTracks -> ShelfViewHolder.ThreeTracks( + parent, listener, { tracks } + ) + } + + override fun onBindViewHolder( + holder: ShelfViewHolder<*>, position: Int + ) { + holder.itemView.applyTranslationAndScaleAnimation(scrollAmountX) + holder.scrollX = scrollAmountX + when (holder) { + is ShelfViewHolder.Category -> holder.bind( + position, + currentList.map { it as ShelfType.Category }) + + is ShelfViewHolder.Media -> holder.bind( + position, + currentList.map { it as ShelfType.Media }) + + is ShelfViewHolder.ThreeTracks -> holder.bind( + position, + currentList.map { it as ShelfType.ThreeTracks }) + } + holder.onCurrentChanged(current) + } + + var current: PlayerState.Current? = null + + override fun onViewDetachedFromWindow(holder: ShelfViewHolder<*>) { + holder.onCurrentChanged(current) + } + + override fun onViewAttachedToWindow(holder: ShelfViewHolder<*>) { + holder.onCurrentChanged(current) + } + + private var scrollAmountX: Int = 0 + private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollAmountX = dx + } + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + private fun onEachViewHolder(action: ShelfViewHolder<*>.() -> Unit) { + recyclerView?.let { rv -> + for (i in 0 until rv.childCount) { + val holder = rv.getChildViewHolder(rv.getChildAt(i)) as? ShelfViewHolder<*> + holder?.action() + } + } + } + + fun onCurrentChanged(current: PlayerState.Current?) { + this.current = current + onEachViewHolder { onCurrentChanged(current) } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaGridViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaGridViewHolder.kt new file mode 100644 index 0000000..df2dd36 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaGridViewHolder.kt @@ -0,0 +1,70 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfMediaGridBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.applyCover +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle + +class MediaGridViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + getAllTracks: (FeedType) -> Pair, Int>, + private val binding: ItemShelfMediaGridBinding = ItemShelfMediaGridBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + private var feed: FeedType.MediaGrid? = null + + init { + binding.coverContainer.cover.clipToOutline = true + binding.root.setOnClickListener { + when (val item = feed?.item) { + is Track -> { + if (item.isPlayable != Track.Playable.Yes) { + listener.onMediaClicked(it, feed?.extensionId, item, feed?.context) + return@setOnClickListener + } + val (tracks, pos) = getAllTracks(feed!!) + listener.onTracksClicked(it, feed?.extensionId, feed?.context, tracks, pos) + } + + else -> listener.onMediaClicked(it, feed?.extensionId, item, feed?.context) + } + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + true + } + } + + override fun bind(feed: FeedType.MediaGrid) = with(binding) { + this@MediaGridViewHolder.feed = feed + val item = feed.item + val index = feed.number + title.text = if (index == null) item.title + else root.context.getString(R.string.n_dot_x, index + 1, item.title) + val subtitleText = item.subtitle(root.context) + subtitle.text = subtitleText + subtitle.isVisible = !subtitleText.isNullOrBlank() + coverContainer.run { applyCover(item, cover, listBg1, listBg2, icon) } + } + + + override fun onCurrentChanged(current: PlayerState.Current?) { + val isPlaying = current.isPlaying(feed?.item?.id) + binding.coverContainer.isPlaying.isVisible = isPlaying + (binding.coverContainer.isPlaying.icon as Animatable).start() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaViewHolder.kt new file mode 100644 index 0000000..47c4e06 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/MediaViewHolder.kt @@ -0,0 +1,153 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.content.Context +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.ImageView +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.ui.media.MediaHeaderAdapter.Companion.playableString +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto + +class MediaViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + getAllTracks: (FeedType) -> Pair, Int>, + private val binding: ItemShelfMediaBinding = ItemShelfMediaBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), +) : FeedViewHolder(binding.root) { + var feed: FeedType.Media? = null + + init { + binding.coverContainer.cover.clipToOutline = true + binding.root.setOnClickListener { + when (val item = feed?.item) { + is Track -> { + if (item.isPlayable != Track.Playable.Yes) { + listener.onMediaClicked(it, feed?.extensionId, item, feed?.context) + return@setOnClickListener + } + val (tracks, pos) = getAllTracks(feed!!) + listener.onTracksClicked(it, feed?.extensionId, feed?.context, tracks, pos) + } + + else -> listener.onMediaClicked(it, feed?.extensionId, item, feed?.context) + } + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + true + } + + binding.more.setOnClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + } + binding.play.setOnClickListener { + listener.onPlayClicked( + it, feed?.extensionId, feed?.item, null, false + ) + } + } + + override fun bind(feed: FeedType.Media) { + this.feed = feed + binding.bind(feed.item, feed.number?.toInt()) + } + + override fun canBeSwiped() = feed?.item is Track + override fun onSwipe() = feed + + override fun onCurrentChanged(current: PlayerState.Current?) { + val isPlaying = current.isPlaying(feed?.item?.id) + binding.coverContainer.isPlaying.isVisible = isPlaying + (binding.coverContainer.isPlaying.icon as Animatable).start() + } + + companion object { + fun ItemShelfMediaBinding.bind(item: EchoMediaItem, index: Int? = null) { + title.text = if (index == null) item.title + else root.context.getString(R.string.n_dot_x, index + 1, item.title) + val subtitleText = item.subtitle(root.context) + subtitle.text = subtitleText + subtitle.isVisible = !subtitleText.isNullOrBlank() + coverContainer.run { + applyCover(item, cover, listBg1, listBg2, icon) + isPlaying.setBackgroundResource( + if (item is Artist) R.drawable.rounded_rectangle_cover_profile + else R.drawable.rounded_rectangle_cover + ) + } + play.isVisible = item !is Track + } + + val EchoMediaItem.placeHolder + get() = when (this) { + is Track -> R.drawable.art_music + is Artist -> R.drawable.art_artist + is Album -> R.drawable.art_album + is Playlist -> R.drawable.art_library_music + is Radio -> R.drawable.art_sensors + } + + val EchoMediaItem.icon + get() = when (this) { + is Track -> R.drawable.ic_music + is Artist -> R.drawable.ic_artist + is Album -> R.drawable.ic_album + is Playlist -> R.drawable.ic_library_music + is Radio -> R.drawable.ic_sensors + } + + fun applyCover( + item: EchoMediaItem, + cover: ImageView, + listBg1: View, + listBg2: View, + icon: ImageView, + ) { + icon.isVisible = when (item) { + is Track, is Artist, is Album -> false + else -> true + } + icon.setImageResource(item.icon) + cover.setBackgroundResource( + if (item is Artist) R.drawable.rounded_rectangle_cover_profile + else R.drawable.rounded_rectangle_cover + ) + val bgVisible = item is EchoMediaItem.Lists + listBg1.isVisible = bgVisible + listBg2.isVisible = bgVisible + cover.updateLayoutParams { + height = if (item is Artist) width else WRAP_CONTENT + } + item.cover.loadInto(cover, item.placeHolder) + } + + fun EchoMediaItem.subtitle(context: Context) = when (this) { + is Track -> playableString(context) ?: subtitleWithE + else -> subtitleWithE + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoHorizontalViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoHorizontalViewHolder.kt new file mode 100644 index 0000000..aa17c9d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoHorizontalViewHolder.kt @@ -0,0 +1,73 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfVideoHorizontalBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.icon +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.placeHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto + +class VideoHorizontalViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + getAllTracks: (FeedType) -> Pair, Int>, + private val binding: ItemShelfVideoHorizontalBinding = ItemShelfVideoHorizontalBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + var feed: FeedType.Video? = null + + init { + binding.artistCover.clipToOutline = true + binding.cover.clipToOutline = true + binding.root.setOnClickListener { + val track = feed?.item + if (track?.isPlayable != Track.Playable.Yes) { + listener.onMediaClicked(it, feed?.extensionId, track, feed?.context) + return@setOnClickListener + } + val (tracks, pos) = getAllTracks(feed!!) + listener.onTracksClicked(it, feed?.extensionId, feed?.context, tracks, pos) + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + true + } + binding.more.setOnClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + } + } + + override fun bind(feed: FeedType.Video) = with(binding) { + this@VideoHorizontalViewHolder.feed = feed + val track = feed.item + title.text = track.title + val sub = track.subtitle(root.context) + subtitle.text = sub + subtitle.isVisible = !sub.isNullOrBlank() + track.cover.loadInto(binding.cover, track.placeHolder) + val artist = track.artists.firstOrNull() + artist?.cover.loadInto(binding.artistCover, artist?.icon) + artistCover.isVisible = artist != null + } + + override fun onCurrentChanged(current: PlayerState.Current?) { + val isPlaying = current.isPlaying(feed?.item?.id) + binding.isPlaying.isVisible = isPlaying + (binding.isPlaying.icon as Animatable).start() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoViewHolder.kt new file mode 100644 index 0000000..08eb6b7 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/VideoViewHolder.kt @@ -0,0 +1,74 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders + +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfVideoBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedType +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.icon +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.placeHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto + +class VideoViewHolder( + parent: ViewGroup, + listener: FeedClickListener, + getAllTracks: (FeedType) -> Pair, Int>, + private val binding: ItemShelfVideoBinding = ItemShelfVideoBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) +) : FeedViewHolder(binding.root) { + + var feed: FeedType.Video? = null + + init { + binding.artistCover.clipToOutline = true + binding.cover.clipToOutline = true + binding.root.setOnClickListener { + val track = feed?.item + if (track?.isPlayable != Track.Playable.Yes) { + listener.onMediaClicked(it, feed?.extensionId, track, feed?.context) + return@setOnClickListener + } + val (tracks, pos) = getAllTracks(feed!!) + listener.onTracksClicked(it, feed?.extensionId, feed?.context, tracks, pos) + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + true + } + binding.more.setOnClickListener { + listener.onMediaLongClicked( + it, feed?.extensionId, feed?.item, + feed?.context, feed?.tabId, bindingAdapterPosition + ) + } + } + + override fun bind(feed: FeedType.Video) = with(binding) { + this@VideoViewHolder.feed = feed + val track = feed.item + title.text = track.title + val sub = track.subtitle(root.context) + subtitle.text = sub + subtitle.isVisible = !sub.isNullOrBlank() + track.cover.loadInto(binding.cover, track.placeHolder) + val artist = track.artists.firstOrNull() + artist?.cover.loadInto(binding.artistCover, artist?.icon) + artistCover.isVisible = artist != null + } + + override fun onCurrentChanged(current: PlayerState.Current?) { + val isPlaying = current.isPlaying(feed?.item?.id) + binding.isPlaying.isVisible = isPlaying + (binding.isPlaying.icon as Animatable).start() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfType.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfType.kt new file mode 100644 index 0000000..1ab0bed --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfType.kt @@ -0,0 +1,48 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders.shelf + +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track + +sealed interface ShelfType { + enum class Enum { + Category, Media, ThreeTracks + } + + val extensionId: String + val tabId: String? + val type: Enum + val id: String + val context: EchoMediaItem? + + class Category( + override val extensionId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val category: Shelf.Category, + ) : ShelfType { + override val type = Enum.Category + override val id = category.id + } + + class Media( + override val extensionId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val media: EchoMediaItem, + ) : ShelfType { + override val type = Enum.Media + override val id = media.id + } + + class ThreeTracks( + override val extensionId: String, + override val context: EchoMediaItem?, + override val tabId: String?, + val number: Int?, + val tracks: Triple, + ) : ShelfType { + override val type = Enum.ThreeTracks + override val id = tracks.run { "${first.id}-${second?.id}-${third?.id}" } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfViewHolder.kt new file mode 100644 index 0000000..f1e52b0 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/feed/viewholders/shelf/ShelfViewHolder.kt @@ -0,0 +1,187 @@ +package dev.brahmkshatriya.echo.ui.feed.viewholders.shelf + +import android.graphics.drawable.Animatable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfListsMediaBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfListsThreeTracksBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.viewholders.CategoryViewHolder.Companion.bind +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.applyCover +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.bind +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +sealed class ShelfViewHolder(view: View) : ScrollAnimViewHolder(view) { + var scrollX = 0 + abstract fun bind(index: Int, list: List) + open fun onCurrentChanged(current: PlayerState.Current?) {} + + class Category( + parent: ViewGroup, + listener: FeedClickListener, + private val binding: ItemShelfCategoryBinding = ItemShelfCategoryBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ShelfViewHolder(binding.root) { + + private var item: ShelfType.Category? = null + + init { + binding.root.setOnClickListener { + listener.openFeed( + it, + item?.extensionId, + item?.id, + item?.category?.title, + item?.category?.subtitle, + item?.category?.feed + ) + } + binding.root.updateLayoutParams { width = WRAP_CONTENT } + binding.icon.clipToOutline = true + } + + override fun bind(index: Int, list: List) { + val item = list[index] + this.item = item + binding.bind(item.category) + } + } + + class Media( + parent: ViewGroup, + listener: FeedClickListener, + private val binding: ItemShelfListsMediaBinding = ItemShelfListsMediaBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ShelfViewHolder(binding.root) { + var shelf: ShelfType.Media? = null + + init { + binding.coverContainer.cover.clipToOutline = true + binding.root.setOnClickListener { + when (val item = shelf?.media) { + is Track -> { + if (item.isPlayable != Track.Playable.Yes) { + listener.onMediaClicked(it, shelf?.extensionId, item, shelf?.context) + } else listener.onTracksClicked( + it, shelf?.extensionId, shelf?.context, listOf(item), 0 + ) + } + + else -> listener.onMediaClicked(it, shelf?.extensionId, item, shelf?.context) + } + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, shelf?.extensionId, shelf?.media, + shelf?.context, shelf?.tabId, bindingAdapterPosition + ) + true + } + } + + override fun bind(index: Int, list: List) { + val shelf = list[index] + this.shelf = shelf + binding.bind(shelf.media) + } + + override fun onCurrentChanged(current: PlayerState.Current?) { + val isPlaying = current.isPlaying(shelf?.media?.id) + binding.coverContainer.isPlaying.isVisible = isPlaying + (binding.coverContainer.isPlaying.icon as Animatable).start() + } + } + + class ThreeTracks( + parent: ViewGroup, + listener: FeedClickListener, + getAllTracks: () -> List, + binding: ItemShelfListsThreeTracksBinding = ItemShelfListsThreeTracksBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ShelfViewHolder(binding.root) { + private val bindings = listOf(binding.track1, binding.track2, binding.track3) + + private var shelf: ShelfType.ThreeTracks? = null + + init { + bindings.forEachIndexed { index, binding -> + binding.coverContainer.cover.clipToOutline = true + val actualIndex = shelf?.number?.let { it * 3 + index } ?: index + binding.root.setOnClickListener { view -> + val tracks = getAllTracks() + val pos = shelf?.number?.let { it * 3 + index } ?: index + val track = tracks.getOrNull(index) + if (track?.isPlayable != Track.Playable.Yes) listener.onMediaClicked( + view, shelf?.extensionId, track, shelf?.context + ) else listener.onTracksClicked( + view, shelf?.extensionId, shelf?.context, tracks, pos + ) + } + binding.root.setOnLongClickListener { + listener.onMediaLongClicked( + it, shelf?.extensionId, shelf?.tracks?.toList()?.getOrNull(index), + shelf?.context, shelf?.tabId, actualIndex + ) + true + } + binding.more.setOnClickListener { + listener.onMediaLongClicked( + it, shelf?.extensionId, shelf?.tracks?.toList()?.getOrNull(index), + shelf?.context, shelf?.tabId, actualIndex + ) + } + } + } + + override fun bind(index: Int, list: List) { + val shelf = list[index] + this.shelf = shelf + val number = shelf.number + val tracks = shelf.tracks.toList() + bindings.forEachIndexed { index, view -> + val track = tracks.getOrNull(index) + view.root.isVisible = track != null + if (track == null) return@forEachIndexed + view.bind(track, number?.let { it * 3 + index }) + } + } + + override fun onCurrentChanged(current: PlayerState.Current?) { + val tracks = shelf?.tracks?.toList() ?: return + bindings.forEachIndexed { index, binding -> + val track = tracks.getOrNull(index) ?: return@forEachIndexed + val isPlaying = current.isPlaying(track.id) + binding.coverContainer.isPlaying.isVisible = isPlaying + (binding.coverContainer.isPlaying.icon as Animatable).start() + } + } + } + + companion object { + fun ItemShelfListsMediaBinding.bind(item: EchoMediaItem) { + val gravity = if (item is Artist) Gravity.CENTER else Gravity.NO_GRAVITY + title.text = item.title + title.gravity = gravity + val sub = item.subtitle(root.context) + subtitle.text = sub + subtitle.gravity = gravity + subtitle.isVisible = !sub.isNullOrBlank() + coverContainer.run { applyCover(item, cover, listBg1, listBg2, icon) } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HeaderAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HeaderAdapter.kt new file mode 100644 index 0000000..ac4c7d3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HeaderAdapter.kt @@ -0,0 +1,142 @@ +package dev.brahmkshatriya.echo.ui.main + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.fragment.app.Fragment +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.User +import dev.brahmkshatriya.echo.databinding.ItemMainHeaderBinding +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toEntity +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionsListBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListViewModel +import dev.brahmkshatriya.echo.ui.settings.SettingsBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class HeaderAdapter( + private val fragment: Fragment, +) : ScrollAnimRecyclerAdapter(), GridAdapter { + private val viewModel by fragment.activityViewModel() + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun getItemCount() = 1 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(parent) + val binding = holder.binding + val parentFragmentManager = fragment.parentFragmentManager + binding.extensionsCont.setOnClickListener { + ExtensionsListBottomSheet.newInstance(ExtensionType.MUSIC) + .show(parentFragmentManager, null) + } + + binding.accountsCont.setOnClickListener { + SettingsBottomSheet().show(parentFragmentManager, null) + } + return holder + } + + private var extension: MusicExtension? = null + set(value) { + field = value + notifyItemChanged(0) + } + + private var triple: Triple?, Boolean, List>> = + Triple(null, false, emptyList()) + set(value) { + field = value + notifyItemChanged(0) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = with(holder.binding) { + super.onBindViewHolder(holder, position) + val context = root.context + title.text = extension?.name ?: context.getString(R.string.app_name) + extensions.loadBigIcon(extension?.metadata?.icon, R.drawable.ic_extension_32dp) + extensionsCont.setLoopedLongClick( + viewModel.extensionLoader.music.value.filter { it.isEnabled }, + { viewModel.extensionLoader.current.value } + ) { + viewModel.extensionLoader.setupMusicExtension(it, true) + } + + val (ext, isLoginClient, all) = triple + accounts.loadBigIcon( + viewModel.currentUser.value?.cover, + if (isLoginClient) R.drawable.ic_account_circle_32dp else R.drawable.ic_settings_outline_32dp + ) + accountsCont.setLoopedLongClick(all, { all.find { it.second } }) { + ext ?: return@setLoopedLongClick + viewModel.setLoginUser(it.first.toEntity(ext.type, ext.id)) + } + } + + class ViewHolder( + parent: ViewGroup, + val binding: ItemMainHeaderBinding = ItemMainHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ScrollAnimViewHolder(binding.root) + + init { + with(fragment) { + + observe(viewModel.extensionLoader.current) { + viewModel.currentExtension.value = it + extension = it + } + observe(viewModel.allUsersWithClient) { + triple = it + } + } + } + + companion object { + fun View.setLoopedLongClick( + list: List, + getCurrent: (View) -> T?, + onSelect: (T) -> Unit, + ) { + setOnLongClickListener { + val current = getCurrent(this) + val index = list.indexOf(current) + if (index == -1) return@setOnLongClickListener false + val next = list[(index + 1) % list.size] + if (next == current) return@setOnLongClickListener false + onSelect(next) + true + } + } + + fun ImageView.loadBigIcon(image: ImageHolder?, placeholder: Int) { + val color = ColorStateList.valueOf( + MaterialColors.getColor( + this, + androidx.appcompat.R.attr.colorControlNormal + ) + ) + image.loadAsCircle(this) { + if (it == null) { + imageTintList = color + setImageResource(placeholder) + } else { + imageTintList = null + setImageDrawable(it) + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HomeFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HomeFragment.kt new file mode 100644 index 0000000..28234a9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/HomeFragment.kt @@ -0,0 +1,86 @@ +package dev.brahmkshatriya.echo.ui.main + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Buttons.Companion.EMPTY +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.databinding.FragmentHomeBinding +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.configure +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionsListBottomSheet +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.feed.FeedData +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.main.MainFragment.Companion.applyInsets +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class HomeFragment : Fragment(R.layout.fragment_home) { + + private val feedData by lazy { + val vm by viewModel() + val id = "home" + vm.getFeedData(id, EMPTY, cached = { + val curr = current.value!! + val feed = Cached.getFeedShelf(app, curr.id, id).getOrThrow() + FeedData.State(curr.id, null, feed) + }) { + val curr = current.value!! + val feed = Cached.savingFeed( + app, curr, id, + curr.getAs> { loadHomeFeed() }.getOrThrow() + ) + FeedData.State(curr.id, null, feed) + } + } + + private val listener by lazy { getFeedListener(requireParentFragment()) } + private val feedAdapter by lazy { getFeedAdapter(feedData, listener) } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentHomeBinding.bind(view) + setupTransition(view, false, MaterialSharedAxis.Y) + applyInsets(binding.recyclerView, binding.appBarOutline) { + binding.swipeRefresh.configure(it) + } + val uiViewModel by activityViewModel() + observe(uiViewModel.navigationReselected) { + if (it != 0) return@observe + ExtensionsListBottomSheet.newInstance(ExtensionType.MUSIC) + .show(parentFragmentManager, null) + } + observe( + uiViewModel.navigation.combine(feedData.backgroundImageFlow) { a, b -> a to b } + ) { (curr, bg) -> + if (curr != 0) return@observe + uiViewModel.currentNavBackground.value = bg + } + applyBackPressCallback() + getTouchHelper(listener).attachToRecyclerView(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + feedAdapter.withLoading(this, HeaderAdapter(this)) + ) + binding.swipeRefresh.run { + setOnRefreshListener { feedData.refresh() } + observe(feedData.isRefreshingFlow) { + isRefreshing = it + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/LibraryFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/LibraryFragment.kt new file mode 100644 index 0000000..f98bb1c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/LibraryFragment.kt @@ -0,0 +1,123 @@ +package dev.brahmkshatriya.echo.ui.main + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.LibraryFeedClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.databinding.FragmentLibraryBinding +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.configure +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.feed.FeedData +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.main.MainFragment.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.playlist.create.CreatePlaylistBottomSheet +import dev.brahmkshatriya.echo.ui.settings.SettingsBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class LibraryFragment : Fragment(R.layout.fragment_library) { + private val feedData by lazy { + val vm by viewModel() + val id = "library" + vm.getFeedData(id, cached = { + val curr = current.value!! + val feed = Cached.getFeedShelf(app, curr.id, id).getOrThrow() + FeedData.State(curr.id, null, feed) + }) { + val curr = current.value!! + val feed = Cached.savingFeed( + app, curr, id, + curr.getAs> { loadLibraryFeed() }.getOrThrow() + ) + FeedData.State(curr.id, null, feed) + } + } + + private val listener by lazy { getFeedListener(requireParentFragment()) } + private val feedAdapter by lazy { getFeedAdapter(feedData, listener) } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentLibraryBinding.bind(view) + setupTransition(view, false, MaterialSharedAxis.Y) + val headerAdapter = HeaderAdapter(this) + val uiViewModel by activityViewModel() + observe(uiViewModel.navigationReselected) { + if (it != 2) return@observe + SettingsBottomSheet().show(parentFragmentManager, null) + } + observe( + uiViewModel.navigation.combine(feedData.backgroundImageFlow) { a, b -> a to b } + ) { (curr, bg) -> + if (curr != 2) return@observe + uiViewModel.currentNavBackground.value = bg + } + applyInsets(binding.recyclerView, binding.appBarOutline, 72) { + binding.createPlaylistContainer.applyInsets(it) + binding.swipeRefresh.configure(it) + } + applyBackPressCallback() + getTouchHelper(listener).attachToRecyclerView(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + feedAdapter.withLoading(this, headerAdapter) + ) + binding.swipeRefresh.run { + setOnRefreshListener { feedData.refresh() } + observe(feedData.isRefreshingFlow) { + isRefreshing = it + } + } + + observe(feedData.current) { + binding.createPlaylist.isVisible = it?.isClient() ?: false + } + val parent = requireParentFragment() + binding.createPlaylist.setOnClickListener { + CreatePlaylistBottomSheet().show(parent.parentFragmentManager, null) + } + + parent.parentFragmentManager.setFragmentResultListener("createPlaylist", this) { _, data -> + val extensionId = data.getString("extensionId") + val playlist = data.getSerialized("playlist")?.getOrNull() + if (extensionId != null && playlist != null) createSnack( + Message( + getString(R.string.x_created, playlist.title), + Message.Action(getString(R.string.view)) { + listener.onMediaClicked(null, extensionId, playlist, null) + } + ) + ) + feedData.refresh() + } + + parent.parentFragmentManager.setFragmentResultListener("deleted", this) { _, _ -> + feedData.refresh() + } + + parent.parentFragmentManager.setFragmentResultListener("reloadLibrary", this) { _, _ -> + feedData.refresh() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/MainFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/MainFragment.kt new file mode 100644 index 0000000..cc24c27 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/MainFragment.kt @@ -0,0 +1,115 @@ +package dev.brahmkshatriya.echo.ui.main + +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.FragmentMainBinding +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.addIfNull +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.BACKGROUND_GRADIENT +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyGradient +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.main.search.SearchFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isRTL +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import kotlin.math.max + +class MainFragment : Fragment() { + + private var binding by autoCleared() + private val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentMainBinding.inflate(inflater, container, false) + return binding.root + } + + private inline fun Fragment.addIfNull(tag: String): String { + addIfNull(R.id.main_fragment_container_view, tag) + return tag + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + applyPlayerBg(view) { + mainBgDrawable.combine(currentNavBackground) { a, b -> b ?: a } + } + observe(viewModel.navigation) { + val toShow = when (it) { + 1 -> addIfNull("search") + 2 -> addIfNull("library") + else -> addIfNull("home") + } + + childFragmentManager.commit(true) { + childFragmentManager.fragments.forEach { fragment -> + if (fragment.tag != toShow) hide(fragment) + else show(fragment) + } + setReorderingAllowed(true) + } + } + } + + companion object { + fun Fragment.applyPlayerBg( + view: View, + imageFlow: UiViewModel.() -> Flow + ): UiViewModel { + val uiViewModel by activityViewModel() + val combined = uiViewModel.imageFlow() + observe(combined) { applyGradient(view, it) } + return uiViewModel + } + + fun Fragment.applyInsets( + recyclerView: RecyclerView, + outline: View, + bottom: Int = 0, + block: UiViewModel.(UiViewModel.Insets) -> Unit = {} + ) { + recyclerView.run { + val height = 48.dpToPx(context) + val settings = context.getSettings() + val isGradient = settings.getBoolean(BACKGROUND_GRADIENT, true) + val extra = if (isGradient) 0.5f else 0f + setOnScrollChangeListener { _, _, _, _, _ -> + val offset = + computeVerticalScrollOffset().coerceAtMost(height) / height.toFloat() + outline.alpha = max(0f, offset - extra) + } + } + val scroller = FastScrollerHelper.applyTo(recyclerView) + applyInsets { + recyclerView.applyInsets(it, 20, 20, bottom + 4) + outline.updatePadding(top = it.top) + scroller?.setPadding(recyclerView.run { + val pad = 8.dpToPx(context) + val isRtl = context.isRTL() + val left = if (!isRtl) it.start else it.end + val right = if (!isRtl) it.end else it.start + Rect(left + pad, it.top + pad, right + pad, it.bottom + bottom + pad) + }) + block(it) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchAdapter.kt new file mode 100644 index 0000000..fb73521 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchAdapter.kt @@ -0,0 +1,63 @@ +package dev.brahmkshatriya.echo.ui.main.search + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.common.models.QuickSearchItem +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter + +class QuickSearchAdapter( + val listener: Listener, +) : ScrollAnimListAdapter(DiffCallback) { + data class Item( + val extensionId: String, + val actual: QuickSearchItem, + ) + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + if (oldItem.extensionId != newItem.extensionId) return false + return oldItem.actual.sameAs(newItem.actual) + } + + override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem + } + + interface Listener { + fun onClick(item: Item, transitionView: View) + fun onLongClick(item: Item, transitionView: View): Boolean + fun onInsert(item: Item) + fun onDeleteClick(item: Item) + } + + override fun getItemViewType(position: Int) = when (getItem(position).actual) { + is QuickSearchItem.Query -> 0 + is QuickSearchItem.Media -> 1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + 0 -> QuickSearchViewHolder.Query.create(parent) + 1 -> QuickSearchViewHolder.Media.create(parent) + else -> throw IllegalArgumentException("Unknown viewType: $viewType") + } + + override fun onBindViewHolder(holder: QuickSearchViewHolder, position: Int) { + val item = getItem(position) ?: return + holder.bind(item) + holder.itemView.setOnClickListener { + listener.onClick(item, holder.transitionView) + } + holder.itemView.setOnLongClickListener { + listener.onLongClick(item, holder.transitionView) + } + holder.insertView.setOnClickListener { + listener.onInsert(item) + } + + holder.deleteView.isVisible = item.actual.searched + holder.deleteView.setOnClickListener { + listener.onDeleteClick(item) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchViewHolder.kt new file mode 100644 index 0000000..69742a4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/QuickSearchViewHolder.kt @@ -0,0 +1,78 @@ +package dev.brahmkshatriya.echo.ui.main.search + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.common.models.QuickSearchItem +import dev.brahmkshatriya.echo.databinding.ItemQuickSearchMediaBinding +import dev.brahmkshatriya.echo.databinding.ItemQuickSearchQueryBinding +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.placeHolder +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +sealed class QuickSearchViewHolder(itemView: View) : ScrollAnimViewHolder(itemView) { + abstract fun bind(item: QuickSearchAdapter.Item) + abstract val insertView: View + abstract val deleteView: View + open val transitionView: View + get() = this.insertView + + class Query(val binding: ItemQuickSearchQueryBinding) : QuickSearchViewHolder(binding.root) { + override val insertView: View + get() = binding.insert + + override val deleteView: View + get() = binding.delete + + override fun bind(item: QuickSearchAdapter.Item) { + val item = item.actual as QuickSearchItem.Query + binding.history.visibility = if (item.searched) View.VISIBLE else View.INVISIBLE + binding.query.text = item.query + } + + companion object { + fun create( + parent: ViewGroup + ): QuickSearchViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return Query( + ItemQuickSearchQueryBinding.inflate(layoutInflater, parent, false) + ) + } + } + } + + class Media(val binding: ItemQuickSearchMediaBinding) : QuickSearchViewHolder(binding.root) { + override val insertView: View + get() = binding.insert + + override val deleteView: View + get() = binding.delete + + override val transitionView: View + get() = binding.coverContainer + + override fun bind(item: QuickSearchAdapter.Item) { + val item = item.actual as QuickSearchItem.Media + binding.title.text = item.media.title + val subtitle = item.media.subtitle(binding.root.context) + binding.subtitle.text = subtitle + binding.subtitle.isVisible = !subtitle.isNullOrEmpty() + transitionView.transitionName = ("quick" + item.media.id).hashCode().toString() + item.media.cover.loadInto(binding.cover, item.media.placeHolder) + } + + companion object { + fun create( + parent: ViewGroup + ): QuickSearchViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return Media( + ItemQuickSearchMediaBinding.inflate(layoutInflater, parent, false) + ) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchBarAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchBarAdapter.kt new file mode 100644 index 0000000..31b9cb9 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchBarAdapter.kt @@ -0,0 +1,39 @@ +package dev.brahmkshatriya.echo.ui.main.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.google.android.material.search.SearchView +import dev.brahmkshatriya.echo.databinding.ItemSearchBarBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class SearchBarAdapter( + val viewModel: SearchViewModel, + val searchView: SearchView, +) : ScrollAnimRecyclerAdapter(), GridAdapter { + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun getItemCount() = 1 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(parent, viewModel, searchView) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + holder.bind() + } + + class ViewHolder( + parent: ViewGroup, + val viewModel: SearchViewModel, + val searchView: SearchView, + private val binding: ItemSearchBarBinding = ItemSearchBarBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + fun bind() { + searchView.setupWithSearchBar(binding.root) + binding.root.setText(viewModel.queryFlow.value.takeIf { it.isNotBlank() }) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchFragment.kt new file mode 100644 index 0000000..ffe4541 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchFragment.kt @@ -0,0 +1,194 @@ +package dev.brahmkshatriya.echo.ui.main.search + +import android.os.Bundle +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.SearchFeedClient +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Buttons.Companion.EMPTY +import dev.brahmkshatriya.echo.common.models.QuickSearchItem +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.databinding.FragmentSearchBinding +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.configure +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.feed.FeedData +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.main.HeaderAdapter +import dev.brahmkshatriya.echo.ui.main.MainFragment.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.main.search.SearchViewModel.Companion.saveInHistory +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SearchFragment : Fragment(R.layout.fragment_search) { + + private val argId by lazy { arguments?.getString("extensionId") } + private val searchViewModel by viewModel() + + private var extensionId = "" + + private val feedData by lazy { + val vm by viewModel() + val id = "search" + vm.getFeedData( + id, + EMPTY, + false, + searchViewModel.queryFlow, + cached = { + val curr = music.getExtension(argId) ?: current.value!! + val query = searchViewModel.queryFlow.value + val feed = Cached.getFeedShelf(app, curr.id, "$id-$query") + FeedData.State(curr.id, null, feed.getOrThrow()) + } + ) { + val curr = music.getExtension(argId) ?: current.value!! + val query = searchViewModel.queryFlow.value + curr.saveInHistory(vm.app.context, query) + val feed = Cached.savingFeed( + app, curr, "$id-$query", + curr.getAs> { loadSearchFeed(query) }.getOrThrow() + ) + extensionId = curr.id + FeedData.State(curr.id, null, feed) + } + } + + private val listener by lazy { + getFeedListener(if (argId == null) requireParentFragment() else this) + } + + private val feedAdapter by lazy { + getFeedAdapter(feedData, listener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentSearchBinding.bind(view) + setupTransition(view, false, MaterialSharedAxis.Y) + applyInsets(binding.recyclerView, binding.appBarOutline) { + binding.swipeRefresh.configure(it) + } + val uiViewModel by activityViewModel() + observe(uiViewModel.navigationReselected) { + if (it != 1) return@observe + binding.quickSearchView.show() + } + observe(uiViewModel.navigation) { + binding.quickSearchView.hide() + } + observe( + uiViewModel.navigation.combine(feedData.backgroundImageFlow) { a, b -> a to b } + ) { (curr, bg) -> + if (curr != 1) return@observe + uiViewModel.currentNavBackground.value = bg + } + val backCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.quickSearchView.hide() + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback) + binding.quickSearchView.addTransitionListener { v, _, _ -> + backCallback.isEnabled = v.isShowing + } + applyBackPressCallback { + if (it == STATE_EXPANDED) binding.quickSearchView.hide() + } + val searchAdapter = SearchBarAdapter(searchViewModel, binding.quickSearchView) + observe(searchViewModel.queryFlow) { + searchAdapter.notifyItemChanged(0) + binding.quickSearchView.setText(it) + } + getTouchHelper(listener).attachToRecyclerView(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + feedAdapter.withLoading(this, HeaderAdapter(this), searchAdapter) + ) + binding.swipeRefresh.run { + setOnRefreshListener { feedData.refresh() } + observe(feedData.isRefreshingFlow) { + isRefreshing = it + } + } + binding.quickSearchView.editText.setText(searchViewModel.queryFlow.value) + binding.quickSearchView.editText.doOnTextChanged { text, _, _, _ -> + searchViewModel.quickSearch(extensionId, text.toString()) + } + binding.quickSearchView.editText.setOnEditorActionListener { textView, _, _ -> + val query = textView.text.toString() + binding.quickSearchView.hide() + searchViewModel.queryFlow.value = query + false + } + val quickSearchAdapter = QuickSearchAdapter(object : QuickSearchAdapter.Listener { + override fun onClick(item: QuickSearchAdapter.Item, transitionView: View) { + when (val actualItem = item.actual) { + is QuickSearchItem.Query -> { + binding.quickSearchView.editText.run { + setText(actualItem.query) + onEditorAction(imeOptions) + } + } + + is QuickSearchItem.Media -> { + val extensionId = item.extensionId + listener.onMediaClicked(transitionView, extensionId, actualItem.media, null) + } + } + } + + override fun onDeleteClick(item: QuickSearchAdapter.Item) = + searchViewModel.deleteSearch( + item.extensionId, + item.actual, + binding.quickSearchView.editText.text.toString() + ) + + override fun onLongClick(item: QuickSearchAdapter.Item, transitionView: View) = + when (val actualItem = item.actual) { + is QuickSearchItem.Query -> { + onDeleteClick(item) + true + } + + is QuickSearchItem.Media -> { + val extensionId = item.extensionId + listener.onMediaLongClicked( + transitionView, extensionId, actualItem.media, + null, null, -1 + ) + true + } + } + + override fun onInsert(item: QuickSearchAdapter.Item) { + binding.quickSearchView.editText.run { + setText(item.actual.title) + setSelection(length()) + } + } + }) + + binding.quickSearchRecyclerView.adapter = quickSearchAdapter + observe(searchViewModel.quickFeed) { list -> + quickSearchAdapter.submitList(list.map { + QuickSearchAdapter.Item(extensionId, it) + }) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchViewModel.kt new file mode 100644 index 0000000..e9d08ed --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/main/search/SearchViewModel.kt @@ -0,0 +1,79 @@ +package dev.brahmkshatriya.echo.ui.main.search + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.QuickSearchClient +import dev.brahmkshatriya.echo.common.models.QuickSearchItem +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.prefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class SearchViewModel( + private val app: App, + extensionLoader: ExtensionLoader +) : ViewModel() { + val queryFlow = MutableStateFlow("") + private val music = extensionLoader.music + val quickFeed = MutableStateFlow>(emptyList()) + fun quickSearch(extensionId: String, query: String) { + viewModelScope.launch(Dispatchers.IO) { + val extension = music.getExtension(extensionId) + val list = extension?.getIf>(app.throwFlow) { + quickSearch(query) + } ?: defaultQuickSearch(extension, app.context, query) + quickFeed.value = list + } + } + + fun deleteSearch(extensionId: String, item: QuickSearchItem, query: String) { + viewModelScope.launch { + val extension = music.getExtension(extensionId) + extension?.getIf(app.throwFlow) { + deleteQuickSearch(item) + } ?: defaultDeleteSearch(extension, app.context, item) + quickSearch(extensionId, query) + } + } + + companion object { + fun defaultQuickSearch( + extension: Extension<*>?, context: Context, query: String + ): List { + val setting = extension?.prefs(context) ?: return emptyList() + if (query.isNotBlank()) return emptyList() + return getHistory(setting).map { QuickSearchItem.Query(it, true) } + } + + fun defaultDeleteSearch(extension: Extension<*>?, context: Context, item: QuickSearchItem) { + val setting = extension?.prefs(context) ?: return + val history = getHistory(setting).toMutableList() + history.remove(item.title) + setting.edit { putString("search_history", history.joinToString(",")) } + } + + private fun getHistory(setting: SharedPreferences): List { + return setting.getString("search_history", "") + ?.split(",")?.mapNotNull { + it.takeIf { it.isNotBlank() } + }?.distinct()?.take(5) + ?: emptyList() + } + + fun Extension<*>.saveInHistory(context: Context, query: String) { + if (query.isBlank()) return + val setting = prefs(context) + val history = getHistory(setting).toMutableList() + history.add(0, query) + setting.edit { putString("search_history", history.joinToString(",")) } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/LineAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/LineAdapter.kt new file mode 100644 index 0000000..100d4d8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/LineAdapter.kt @@ -0,0 +1,21 @@ +package dev.brahmkshatriya.echo.ui.media + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.LoadState +import dev.brahmkshatriya.echo.databinding.ItemLineBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimLoadStateAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class LineAdapter : ScrollAnimLoadStateAdapter(), GridAdapter { + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) = ViewHolder(parent) + class ViewHolder( + parent: ViewGroup, + binding: ItemLineBinding = ItemLineBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsFragment.kt new file mode 100644 index 0000000..ce28904 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsFragment.kt @@ -0,0 +1,127 @@ +package dev.brahmkshatriya.echo.ui.media + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.databinding.FragmentMediaDetailsBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.configure +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getFeedAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.Companion.getTouchHelper +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener.Companion.getFeedListener +import dev.brahmkshatriya.echo.ui.feed.FeedViewModel +import dev.brahmkshatriya.echo.ui.media.MediaHeaderAdapter.Companion.getMediaHeaderListener +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MediaDetailsFragment : Fragment(R.layout.fragment_media_details) { + + interface Parent { + val feedId: String + val viewModel: MediaDetailsViewModel + val fromPlayer: Boolean + } + + val parent by lazy { requireParentFragment() as Parent } + val viewModel by lazy { parent.viewModel } + private val feedViewModel by lazy { + requireParentFragment().viewModel().value + } + + private val trackFeedData by lazy { + feedViewModel.getFeedData( + "${parent.feedId}_tracks", + Feed.Buttons(showPlayAndShuffle = true), + true, + viewModel.tracksLoadedFlow, viewModel.trackCachedFlow, + cached = { viewModel.trackCachedFlow.value?.getOrThrow() }, + loader = { viewModel.tracksLoadedFlow.value?.getOrThrow() } + ) + } + + private val feedData by lazy { + feedViewModel.getFeedData( + "${parent.feedId}_feed", + Feed.Buttons(), + false, + viewModel.feedCachedFlow, viewModel.feedLoadedFlow, + cached = { viewModel.feedCachedFlow.value?.getOrThrow() }, + loader = { viewModel.feedLoadedFlow.value?.getOrThrow() } + ) + } + + private val mediaHeaderAdapter by lazy { + MediaHeaderAdapter( + requireParentFragment().getMediaHeaderListener(viewModel), + parent.fromPlayer + ) + } + + private val feedListener by lazy { + if (!parent.fromPlayer) requireParentFragment().getFeedListener() + else FeedClickListener( + requireParentFragment(), + requireActivity().supportFragmentManager, + R.id.navHostFragment + ) { + val uiViewModel by activityViewModel() + uiViewModel.collapsePlayer() + } + } + + private val trackAdapter by lazy { + getFeedAdapter(trackFeedData, feedListener, true) + } + private val feedAdapter by lazy { + getFeedAdapter(feedData, feedListener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentMediaDetailsBinding.bind(view) + FastScrollerHelper.applyTo(binding.recyclerView) + applyInsets(viewModel.uiResultFlow) { + val item = viewModel.uiResultFlow.value?.getOrNull()?.item as? Playlist + val bottom = if (item?.isEditable == true) 72 else 16 + binding.recyclerView.applyContentInsets(it, 20, 8, bottom) + } + val lineAdapter = LineAdapter() + observe(trackFeedData.shouldShowEmpty) { + lineAdapter.loadState = if (it) LoadState.Loading else LoadState.NotLoading(false) + } + observe(viewModel.uiResultFlow) { result -> + mediaHeaderAdapter.result = result + } + getTouchHelper(feedListener).attachToRecyclerView(binding.recyclerView) + configureGridLayout( + binding.recyclerView, + GridAdapter.Concat( + mediaHeaderAdapter, + trackAdapter.withLoading(this), + lineAdapter, + feedAdapter.withLoading(this) + ) + ) + val loadingFlow = viewModel.isRefreshingFlow + .combine(trackFeedData.isRefreshingFlow) { a, b -> a || b } + .combine(feedData.isRefreshingFlow) { a, b -> a || b } + binding.swipeRefresh.run { + configure() + setOnRefreshListener { viewModel.refresh() } + observe(loadingFlow) { + isRefreshing = it + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsViewModel.kt new file mode 100644 index 0000000..abacffc --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaDetailsViewModel.kt @@ -0,0 +1,310 @@ +package dev.brahmkshatriya.echo.ui.media + +import android.content.Context +import android.content.Intent +import androidx.core.app.ShareCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.common.clients.FollowClient +import dev.brahmkshatriya.echo.common.clients.HideClient +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.clients.SaveClient +import dev.brahmkshatriya.echo.common.clients.ShareClient +import dev.brahmkshatriya.echo.common.helpers.PagedData +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Feed.Companion.toFeed +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.Shelf +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.cache.Cached.getFeed +import dev.brahmkshatriya.echo.extensions.cache.Cached.getTracks +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadFeed +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadItem +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadTracks +import dev.brahmkshatriya.echo.ui.feed.FeedData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class MediaDetailsViewModel( + downloader: Downloader, + private val app: App, + private val loadFeeds: Boolean, + extension: Flow, +) : ViewModel() { + val extensionFlow = extension.stateIn(viewModelScope, Eagerly, null) + val downloadsFlow = downloader.flow + + val refreshFlow = MutableSharedFlow() + val cacheResultFlow = MutableStateFlow>?>(null) + val itemResultFlow = MutableStateFlow>?>(null) + + val uiResultFlow = itemResultFlow.combine(cacheResultFlow) { item, cache -> + item ?: cache + }.stateIn(viewModelScope, Eagerly, null) + + val cacheExtensionItemFlow = uiResultFlow.map { + extensionFlow.value to it?.getOrNull()?.item + }.stateIn(viewModelScope, Eagerly, null to null) + + val extensionItemFlow = itemResultFlow.map { result -> + extensionFlow.value to result?.getOrNull()?.item + }.stateIn(viewModelScope, Eagerly, null to null) + + private fun trackFeed(item: EchoMediaItem, extension: Extension<*>) = + if (item is Track) runCatching { + PagedData.Concat( + PagedData.Single { + val album = item.album?.let { loadItem(extension, it).getOrNull() ?: it } + listOfNotNull(album?.toShelf()) + }, + PagedData.Single { + if (item.artists.isEmpty()) return@Single emptyList() + listOf( + Shelf.Lists.Items( + item.id + "_artists", + app.context.getString(R.string.artists), + item.artists.map { + loadItem(extension, it).getOrNull() ?: it + }, + ) + ) + }, + ).toFeed(Feed.Buttons.EMPTY) + } else null + + val trackCachedFlow = cacheExtensionItemFlow.transformLatest { (extension, item) -> + emit(null) + if (!loadFeeds) return@transformLatest + extension ?: return@transformLatest + val item = item ?: cacheResultFlow.value?.getOrNull()?.item ?: return@transformLatest + val feed = trackFeed(item, extension) + ?: getTracks(app, extension.id, item).map { feed -> feed?.map { it.toShelf() } } + emit(feed.map { + it ?: return@map null + FeedData.State(extension.id, item, it) + }) + }.stateIn(viewModelScope, Eagerly, null) + + val tracksLoadedFlow = extensionItemFlow.transformLatest { (extension, item) -> + emit(null) + if (!loadFeeds) return@transformLatest + extension ?: return@transformLatest + item ?: return@transformLatest + val feed = trackFeed(item, extension) + ?: loadTracks(app, extension, item).map { feed -> feed?.map { it.toShelf() } } + emit(feed.map { + it ?: return@map null + FeedData.State(extension.id, item, it) + }) + }.stateIn(viewModelScope, Eagerly, null) + + val feedCachedFlow = cacheExtensionItemFlow.transformLatest { (extension, item) -> + emit(null) + if (!loadFeeds) return@transformLatest + extension ?: return@transformLatest + val item = item ?: cacheResultFlow.value?.getOrNull()?.item ?: return@transformLatest + val feed = getFeed(app, extension.id, item) ?: return@transformLatest + emit(feed.map { + FeedData.State(extension.id, item, it) + }) + }.stateIn(viewModelScope, Eagerly, null) + + val feedLoadedFlow = extensionItemFlow.transformLatest { (extension, item) -> + emit(null) + if (!loadFeeds) return@transformLatest + extension ?: return@transformLatest + item ?: return@transformLatest + val feed = loadFeed(app, extension, item) + emit(feed.map { + it ?: return@map null + FeedData.State(extension.id, item, it) + }) + }.stateIn(viewModelScope, Eagerly, null) + + fun refresh() = viewModelScope.launch { + refreshFlow.emit(Unit) + } + + abstract fun getItem(): Triple? + + fun likeItem(liked: Boolean) = app.scope.launch { + val item = itemResultFlow.value?.getOrNull()?.item ?: return@launch + val extension = extensionFlow.value + like(app, extension, item, liked) + refresh() + } + + fun hideItem(hidden: Boolean) = app.scope.launch { + val item = itemResultFlow.value?.getOrNull()?.item ?: return@launch + val extension = extensionFlow.value + hide(app, extension, item, hidden) + refresh() + } + + fun followItem(followed: Boolean) = app.scope.launch { + val item = itemResultFlow.value?.getOrNull()?.item ?: return@launch + val extension = extensionFlow.value + follow(app, extension, item, followed) + refresh() + } + + fun saveToLibrary(saved: Boolean) = app.scope.launch { + val item = itemResultFlow.value?.getOrNull()?.item ?: return@launch + val extension = extensionFlow.value + save(app, extension, item, saved) + refresh() + } + + fun onShare() = app.scope.launch(Dispatchers.IO) { + val item = itemResultFlow.value?.getOrNull()?.item ?: return@launch + val extension = extensionFlow.value + share(app, extension, item) + } + + val isRefreshing get() = itemResultFlow.value == null + val isRefreshingFlow = itemResultFlow.map { + isRefreshing + } + + companion object { + + suspend fun notFound(app: App, id: Int) { + val notFound = app.context.run { getString(R.string.no_x_found, getString(id)) } + app.messageFlow.emit(Message(notFound)) + } + + suspend fun createMessage(app: App, message: Context.() -> String) { + app.messageFlow.emit(Message(app.context.message())) + } + + suspend fun like( + app: App, extension: Extension<*>?, item: EchoMediaItem, like: Boolean, + ) { + val extension = extension ?: return notFound(app, R.string.extension) + createMessage(app) { + getString( + if (like) R.string.liking_x else R.string.unliking_x, + item.title + ) + } + val result = extension.getIf(app.throwFlow) { + likeItem(item, like) + } + if (result != null) createMessage(app) { + getString( + if (like) R.string.liked_x else R.string.unliked_x, item.title + ) + } + } + + suspend fun hide( + app: App, extension: Extension<*>?, item: EchoMediaItem, hide: Boolean, + ) { + val extension = extension ?: return notFound(app, R.string.extension) + createMessage(app) { + getString( + if (hide) R.string.hiding_x else R.string.unhiding_x, + item.title + ) + } + val result = extension.getIf(app.throwFlow) { + hideItem(item, hide) + } + if (result != null) createMessage(app) { + getString( + if (hide) R.string.hidden_x else R.string.unhidden_x, + item.title + ) + } + } + + suspend fun follow( + app: App, extension: Extension<*>?, item: EchoMediaItem, follow: Boolean, + ) { + val extension = extension ?: return notFound(app, R.string.extension) + createMessage(app) { + getString( + if (follow) R.string.following_x else R.string.unfollowing_x, + item.title + ) + } + val result = extension.getIf(app.throwFlow) { + followItem(item, follow) + } + if (result != null) createMessage(app) { + getString( + if (follow) R.string.followed_x else R.string.unfollowed_x, + item.title + ) + } + } + + suspend fun save( + app: App, extension: Extension<*>?, item: EchoMediaItem, save: Boolean, + ) { + val extension = extension ?: return notFound(app, R.string.extension) + createMessage(app) { + getString( + if (save) R.string.saving_x else R.string.removing_x, + item.title + ) + } + val result = extension.getIf(app.throwFlow) { + saveToLibrary(item, save) + } + if (result != null) createMessage(app) { + getString( + if (save) R.string.saved_x_to_library else R.string.removed_x_from_library, + item.title + ) + } + } + + suspend fun share( + app: App, extension: Extension<*>?, item: EchoMediaItem, + ) { + val extension = extension ?: return notFound(app, R.string.extension) + createMessage(app) { getString(R.string.sharing_x, item.title) } + val url = extension.getIf(app.throwFlow) { + onShare(item) + } ?: return notFound(app, R.string.extension) + val intent = ShareCompat.IntentBuilder(app.context) + .setType("text/plain") + .setChooserTitle("${extension.name} - ${item.title}") + .setText(url) + .createChooserIntent() + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + app.context.startActivity(intent) + } + + private fun Feed.map(transform: (T) -> Shelf) = Feed(tabs) { tab -> + val data = getPagedData.invoke(tab) + Feed.Data( + data.pagedData.map { + val list = it.getOrThrow() + list.map(transform) + }, + data.buttons, + data.background + ) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaFragment.kt new file mode 100644 index 0000000..2a0036e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaFragment.kt @@ -0,0 +1,114 @@ +package dev.brahmkshatriya.echo.ui.media + +import android.os.Bundle +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.databinding.FragmentMediaBinding +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyFabInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyGradient +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.icon +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.placeHolder +import dev.brahmkshatriya.echo.ui.media.more.MediaMoreBottomSheet +import dev.brahmkshatriya.echo.ui.playlist.delete.DeletePlaylistBottomSheet +import dev.brahmkshatriya.echo.ui.playlist.edit.EditPlaylistFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadWithThumb +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class MediaFragment : Fragment(R.layout.fragment_media), MediaDetailsFragment.Parent { + companion object { + fun getBundle(extensionId: String, item: EchoMediaItem, loaded: Boolean) = Bundle().apply { + putString("extensionId", extensionId) + putSerialized("item", item) + putBoolean("loaded", loaded) + } + } + + val args by lazy { requireArguments() } + val extensionId by lazy { args.getString("extensionId")!! } + val item by lazy { args.getSerialized("item")!!.getOrThrow() } + val loaded by lazy { args.getBoolean("loaded") } + + override val fromPlayer = false + override val feedId by lazy { item.id } + + override val viewModel by viewModel { + parametersOf(true, extensionId, item, loaded, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = FragmentMediaBinding.bind(view) + setupTransition(view) + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.appbarOutline.alpha = offset + binding.coverContainer.alpha = 1 - offset + binding.endIcon.alpha = 1 - offset + } + binding.toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + binding.toolBar.setOnMenuItemClickListener { + val item = viewModel.itemResultFlow.value?.getOrNull()?.item ?: item + MediaMoreBottomSheet.newInstance( + id, extensionId, item, !viewModel.isRefreshing + ).show(parentFragmentManager, null) + true + } + applyInsets { + binding.fabContainer.applyFabInsets(it, systemInsets.value) + } + + observe(viewModel.itemResultFlow) { result -> + val item = result?.getOrNull()?.item ?: item + binding.toolBar.title = item.title.trim() + binding.endIcon.setImageResource(item.icon) + if (item is Artist) binding.coverContainer.run { + val maxWidth = 240.dpToPx(context) + radius = maxWidth.toFloat() + updateLayoutParams { + matchConstraintMaxWidth = maxWidth + } + } + item.cover.loadInto(binding.cover, null, item.placeHolder) + item.background.loadWithThumb(view) { applyGradient(view, it) } + val isEditable = (result?.getOrNull()?.item as? Playlist)?.isEditable ?: false + binding.fabEditPlaylist.isVisible = isEditable + binding.fabEditPlaylist.setOnClickListener { + val playlist = item as? Playlist ?: return@setOnClickListener + openFragment( + it, EditPlaylistFragment.getBundle(extensionId, playlist, loaded) + ) + } + } + parentFragmentManager.setFragmentResultListener("reload", this) { _, data -> + if (data.getString("id") == item.id) viewModel.refresh() + } + parentFragmentManager.setFragmentResultListener("delete", this) { _, data -> + val playlist = item as? Playlist ?: return@setFragmentResultListener + DeletePlaylistBottomSheet.show( + requireActivity(), extensionId, playlist, !viewModel.isRefreshing + ) + } + parentFragmentManager.setFragmentResultListener("deleted", this) { _, data -> + if (data.getString("id") == item.id) parentFragmentManager.popBackStack() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaHeaderAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaHeaderAdapter.kt new file mode 100644 index 0000000..3ece23a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaHeaderAdapter.kt @@ -0,0 +1,530 @@ +package dev.brahmkshatriya.echo.ui.media + +import android.content.Context +import android.icu.text.CompactDecimalFormat +import android.text.SpannableString +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.Album.Type.Book +import dev.brahmkshatriya.echo.common.models.Album.Type.Compilation +import dev.brahmkshatriya.echo.common.models.Album.Type.EP +import dev.brahmkshatriya.echo.common.models.Album.Type.LP +import dev.brahmkshatriya.echo.common.models.Album.Type.PreRelease +import dev.brahmkshatriya.echo.common.models.Album.Type.Show +import dev.brahmkshatriya.echo.common.models.Album.Type.Single +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemLineBinding +import dev.brahmkshatriya.echo.databinding.ItemMediaHeaderBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfErrorBinding +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.getFinalTitle +import dev.brahmkshatriya.echo.ui.common.ExceptionUtils.getMessage +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.media.MediaFragment.Companion.getBundle +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ui.SimpleItemSpan +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.toTimeString +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class MediaHeaderAdapter( + private val listener: Listener, + private val fromPlayer: Boolean, +) : ScrollAnimRecyclerAdapter(), GridAdapter { + + interface Listener { + fun onRetry(view: View) + fun onError(view: View, error: Throwable?) + fun onDescriptionClicked(view: View, extensionId: String?, item: EchoMediaItem?) + fun openMediaItem(extensionId: String, item: EchoMediaItem) + fun onFollowClicked(view: View, follow: Boolean) + fun onSavedClicked(view: View, saved: Boolean) + fun onLikeClicked(view: View, liked: Boolean) + fun onPlayClicked(view: View) + fun onRadioClicked(view: View) + fun onShareClicked(view: View) + fun onHideClicked(view: View, hidden: Boolean) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + 0 -> Success(parent, listener, fromPlayer) + 1 -> Error(parent, listener) + 2 -> Loading(parent) + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + when (holder) { + is Success -> { + val state = result?.getOrNull() ?: return + holder.bind(state) + } + + is Error -> { + val error = result?.exceptionOrNull() ?: return + holder.bind(error) + } + + is Loading -> {} + } + } + + override fun getItemCount() = 1 + override fun getItemViewType(position: Int) = when (result?.isSuccess) { + true -> 0 + false -> 1 + null -> 2 + } + + var result: Result>? = null + set(value) { + field = value + notifyItemChanged(0) + } + + sealed class ViewHolder(itemView: View) : ScrollAnimViewHolder(itemView) + class Success( + parent: ViewGroup, + private val listener: Listener, + private val fromPlayer: Boolean, + private val binding: ItemMediaHeaderBinding = ItemMediaHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ViewHolder(binding.root) { + val buttons = binding.run { + listOf( + followButton, playButton, savedButton, likeButton, hideButton, + radioButton, shareButton + ) + } + + init { + binding.followButton.setOnClickListener { + listener.onFollowClicked(it, binding.followButton.isChecked) + it.isEnabled = false + } + binding.savedButton.setOnClickListener { + listener.onSavedClicked(it, binding.savedButton.isChecked) + it.isEnabled = false + } + binding.likeButton.setOnClickListener { + listener.onLikeClicked(it, binding.likeButton.isChecked) + it.isEnabled = false + } + binding.hideButton.setOnClickListener { + listener.onHideClicked(it, binding.hideButton.isChecked) + it.isEnabled = false + } + binding.playButton.setOnClickListener { + listener.onPlayClicked(it) + } + binding.radioButton.setOnClickListener { + listener.onRadioClicked(it) + } + binding.shareButton.setOnClickListener { + listener.onShareClicked(it) + it.isEnabled = false + } + } + + + fun configureButtons() { + val visible = buttons.filter { it.isVisible } + binding.buttonGroup.isVisible = visible.isNotEmpty() + val isNotOne = visible.size > 1 + visible.forEachIndexed { index, button -> + button.isEnabled = true + if (index == 0 && isNotOne) button.run { + updatePaddingRelative( + start = if (icon != null) 16.dpToPx(context) else 24.dpToPx(context), + end = 24.dpToPx(context) + ) + iconPadding = 8.dpToPx(context) + text = contentDescription + } else button.run { + updatePaddingRelative(start = 12.dpToPx(context), end = 12.dpToPx(context)) + iconPadding = 0 + text = null + } + } + binding.buttonGroup.updateLayoutParams { + width = if (isNotOne) ViewGroup.LayoutParams.MATCH_PARENT + else ViewGroup.LayoutParams.WRAP_CONTENT + bottomMargin = if (isNotOne) 0 else (-56).dpToPx(binding.root.context) + } + binding.description.updateLayoutParams { + marginEnd = if (isNotOne) 0 else 48.dpToPx(binding.root.context) + } + } + + var clickEnabled = true + var state: MediaState.Loaded<*>? = null + + fun bind(state: MediaState.Loaded<*>) = with(binding) { + this@Success.state = state + followButton.isVisible = state.isFollowed != null + followButton.isChecked = state.isFollowed ?: false + followButton.contentDescription = root.context.getString( + if (state.isFollowed == true) R.string.unfollow else R.string.follow + ) + + savedButton.isVisible = state.isSaved != null + savedButton.isChecked = state.isSaved ?: false + savedButton.contentDescription = root.context.getString( + if (state.isSaved == true) R.string.unsave else R.string.save + ) + + likeButton.isVisible = state.isLiked != null && !fromPlayer + likeButton.isChecked = state.isLiked ?: false + likeButton.contentDescription = root.context.getString( + if (state.isLiked == true) R.string.unlike else R.string.like + ) + + hideButton.isVisible = state.isHidden != null + hideButton.isChecked = state.isHidden ?: false + hideButton.contentDescription = root.context.getString( + if (state.isHidden == true) R.string.unhide else R.string.hide + ) + + playButton.isVisible = state.item is Track && !fromPlayer && state.item.isPlayable == Track.Playable.Yes + radioButton.isVisible = state.showRadio + shareButton.isVisible = state.showShare + configureButtons() + + explicit.isVisible = state.item.isExplicit + followers.isVisible = state.followers != null + followers.text = state.followers?.let { + val formatter = CompactDecimalFormat.getInstance() + root.context.getString(R.string.x_followers, formatter.format(it)) + } + val span = + root.context.getSpan(true, state.extensionId, state.item) { id, item -> + clickEnabled = false + listener.openMediaItem(id, item) + description.post { clickEnabled = true } + } + description.text = span + description.isVisible = span.isNotEmpty() + } + + init { + binding.run { + description.movementMethod = LinkMovementMethod.getInstance() + description.setOnClickListener { + if (clickEnabled) listener.onDescriptionClicked( + it, + state?.extensionId, + state?.item + ) + } + } + } + } + + class Error( + parent: ViewGroup, + listener: Listener, + private val binding: ItemShelfErrorBinding = ItemShelfErrorBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ViewHolder(binding.root) { + var throwable: Throwable? = null + + init { + binding.errorView.setOnClickListener { + listener.onError(binding.error, throwable) + } + binding.retry.setOnClickListener { + listener.onRetry(it) + } + } + + fun bind(throwable: Throwable) { + this.throwable = throwable + binding.error.run { + transitionName = throwable.hashCode().toString() + text = context.getFinalTitle(throwable) + } + } + } + + class Loading( + parent: ViewGroup, + binding: ItemLineBinding = ItemLineBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ), + ) : ViewHolder(binding.root) { + init { + itemView.alpha = 0f + } + } + + companion object { + private const val MAX_DESC_TEXT = 144 + private fun String.ellipsize() = if (length > MAX_DESC_TEXT) { + substring(0, MAX_DESC_TEXT) + "..." + } else this + + private const val DIVIDER = " • " + fun Context.getSpan( + compact: Boolean, + extensionId: String, + item: EchoMediaItem, + openMediaItem: (String, EchoMediaItem) -> Unit = { a, b -> }, + ): SpannableString = when (item) { + is EchoMediaItem.Lists -> { + val madeBy = item.artists.joinToString(", ") { it.name } + val span = SpannableString(buildString { + val firstRow = listOfNotNull( + getString(item.typeInt), + item.date?.toString(), + ).joinToString(DIVIDER) + val secondRow = listOfNotNull( + item.toTrackString(this@getSpan), + item.duration?.toTimeString() + ).joinToString(DIVIDER) + if (firstRow.isNotEmpty()) appendLine(firstRow) + if (secondRow.isNotEmpty()) appendLine(secondRow) + val desc = item.description + if (desc != null) { + appendLine() + appendLine(if (compact) desc.ellipsize() else desc) + } + if (madeBy.isNotEmpty()) { + appendLine() + appendLine(getString(R.string.by_x, madeBy)) + } + if (item.label != null) { + appendLine() + appendLine(item.label) + } + }.trimEnd('\n').trimStart('\n')) + val madeByIndex = span.indexOf(madeBy) + item.artists.forEach { + val start = span.indexOf(it.name, madeByIndex) + if (start != -1) { + val end = start + it.name.length + val clickableSpan = SimpleItemSpan(this) { + openMediaItem(extensionId, it) + } + span.setSpan( + clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + span + } + + is Artist -> { + val desc = if (compact) item.bio?.ellipsize() else item.bio + SpannableString(desc ?: "") + } + + is Track -> { + SpannableString(buildString { + val firstRow = listOfNotNull( + getString( + when (item.type) { + Track.Type.Song, Track.Type.VideoSong -> R.string.song + Track.Type.Video, Track.Type.HorizontalVideo -> R.string.video + Track.Type.Podcast -> R.string.podcast + } + ), + item.releaseDate + ).joinToString(DIVIDER) + val secondRow = listOfNotNull( + item.duration?.toTimeString(), + if (item.plays != null) { + val formatter = CompactDecimalFormat.getInstance() + getString(R.string.x_plays, formatter.format(item.plays)) + } else null + ).joinToString(DIVIDER) + if (firstRow.isNotEmpty()) appendLine(firstRow) + if (secondRow.isNotEmpty()) appendLine(secondRow) + val notPlayable = item.playableString(this@getSpan) + if (!notPlayable.isNullOrEmpty()) { + appendLine() + appendLine(notPlayable) + } + val desc = item.description + if (desc != null) { + appendLine() + appendLine(if (compact) desc.ellipsize() else desc) + appendLine() + } + val genres = item.genres.joinToString(", ") + if (genres.isNotEmpty()) { + appendLine(getString(R.string.genres_x, genres)) + } + val isrc = item.isrc + if (isrc != null) { + appendLine(getString(R.string.isrc_x, isrc)) + } + val label = item.album?.label + if (label != null) { + appendLine() + appendLine(label) + } + val lastRow = listOfNotNull( + item.albumDiscNumber?.let { + getString(R.string.disc_number_n, it) + }, + item.albumOrderNumber?.let { + getString(R.string.album_order_n, it) + } + ).joinToString(DIVIDER) + if (lastRow.isNotEmpty()) { + appendLine() + appendLine(lastRow) + } + }.trimStart('\n').trimEnd('\n')) + } + } + + fun Context.unfuckedString( + numberStringId: Int, nStringId: Int, count: Int, + ) = runCatching { + resources.getQuantityString(numberStringId, count, count) + }.getOrNull() ?: getString(nStringId, count) + + fun Fragment.getMediaHeaderListener(viewModel: MediaDetailsViewModel) = object : Listener { + override fun onRetry(view: View) { + viewModel.refresh() + } + + override fun onError(view: View, error: Throwable?) { + error ?: return + requireActivity().getMessage(error, view).action?.handler?.invoke() + } + + override fun openMediaItem(extensionId: String, item: EchoMediaItem) { + openFragment(null, getBundle(extensionId, item, false)) + } + + override fun onFollowClicked(view: View, follow: Boolean) { + viewModel.followItem(follow) + } + + override fun onSavedClicked(view: View, saved: Boolean) { + viewModel.saveToLibrary(saved) + } + + override fun onLikeClicked(view: View, liked: Boolean) { + viewModel.likeItem(liked) + } + + override fun onHideClicked(view: View, hidden: Boolean) { + viewModel.hideItem(hidden) + } + + override fun onPlayClicked(view: View) { + val (extensionId, item, loaded) = viewModel.getItem() ?: return + val vm by activityViewModels() + vm.play(extensionId, item, loaded) + } + + override fun onRadioClicked(view: View) { + val (extensionId, item, loaded) = viewModel.getItem() ?: return + val vm by activityViewModels() + vm.radio(extensionId, item, loaded) + } + + override fun onShareClicked(view: View) { + viewModel.onShare() + } + + override fun onDescriptionClicked( + view: View, extensionId: String?, item: EchoMediaItem?, + ) { + item ?: return + extensionId ?: return + val context = requireContext() + var dialog: AlertDialog? = null + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(item.title) + builder.setMessage(context.getSpan(false, extensionId, item) { m, n -> + openMediaItem(m, n) + dialog?.dismiss() + }) + builder.setPositiveButton(getString(R.string.okay)) { d, _ -> + d.dismiss() + } + dialog = builder.create() + dialog.show() + val text = dialog.findViewById(android.R.id.message)!! + text.movementMethod = LinkMovementMethod.getInstance() + } + } + + val EchoMediaItem.Lists.typeInt + get() = when (this) { + is Album -> when (type) { + PreRelease -> R.string.pre_release + Single -> R.string.single + EP -> R.string.ep + LP -> R.string.lp + Compilation -> R.string.compilation + Show -> R.string.show + Book -> R.string.book + null -> R.string.album + } + + is Playlist -> R.string.playlist + is Radio -> R.string.radio + } + + fun EchoMediaItem.Lists.toTrackString(context: Context) = context.run { + val tracks = trackCount?.toInt() + if (tracks != null) { + when (type) { + PreRelease, Single, EP, LP, Compilation -> unfuckedString( + R.plurals.number_songs, R.string.n_songs, tracks + ) + + Show -> unfuckedString( + R.plurals.number_episodes, R.string.n_episodes, tracks + ) + + Book -> unfuckedString( + R.plurals.number_chapters, R.string.n_chapters, tracks + ) + + null -> unfuckedString( + R.plurals.number_tracks, R.string.n_tracks, tracks + ) + } + } else null + } + + fun Track.playableString(context: Context) = when (val play = isPlayable) { + is Track.Playable.No -> context.getString(R.string.not_playable_x, play.reason) + Track.Playable.Yes -> null + Track.Playable.RegionLocked -> context.getString(R.string.unavailable_in_your_region) + Track.Playable.Unreleased -> if (releaseDate != null) context.getString( + R.string.releases_on_x, releaseDate.toString() + ) else context.getString(R.string.not_yet_released) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaViewModel.kt new file mode 100644 index 0000000..8e0955a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaViewModel.kt @@ -0,0 +1,55 @@ +package dev.brahmkshatriya.echo.ui.media + +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadMedia +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch + +class MediaViewModel( + extensionLoader: ExtensionLoader, + downloader: Downloader, + val app: App, + loadFeeds: Boolean, + val extensionId: String, + val item: EchoMediaItem, + val loaded: Boolean, +) : MediaDetailsViewModel( + downloader, app, loadFeeds, + extensionLoader.music.map { list -> list.find { it.id == extensionId } } +) { + + override fun getItem(): Triple { + val result = itemResultFlow.value?.getOrNull()?.item + return Triple( + extensionId, + result ?: item, + loaded || result != null + ) + } + + init { + var force = false + viewModelScope.launch(Dispatchers.IO) { + listOf(extensionFlow, refreshFlow).merge().collectLatest { + itemResultFlow.value = null + cacheResultFlow.value = null + cacheResultFlow.value = Cached.getMedia(app, extensionId, item.id) + .getOrNull()?.let { Result.success(it) } + val extension = extensionFlow.value ?: return@collectLatest + itemResultFlow.value = loadMedia( + app, extension, MediaState.Unloaded(extension.id, item) + ) + force = true + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MediaMoreBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MediaMoreBottomSheet.kt new file mode 100644 index 0000000..fc1230c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MediaMoreBottomSheet.kt @@ -0,0 +1,303 @@ +package dev.brahmkshatriya.echo.ui.media.more + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.DialogMediaMoreBinding +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.builtin.offline.OfflineExtension +import dev.brahmkshatriya.echo.extensions.builtin.unified.UnifiedExtension.Companion.EXTENSION_ID +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.download.DownloadViewModel +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter.Companion.createListener +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.icon +import dev.brahmkshatriya.echo.ui.media.MediaFragment +import dev.brahmkshatriya.echo.ui.media.MediaViewModel +import dev.brahmkshatriya.echo.ui.media.more.MoreButton.Companion.button +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.ui.player.audiofx.AudioEffectsBottomSheet +import dev.brahmkshatriya.echo.ui.player.more.lyrics.LyricsItemAdapter +import dev.brahmkshatriya.echo.ui.player.quality.QualitySelectionBottomSheet +import dev.brahmkshatriya.echo.ui.player.sleep.SleepTimerBottomSheet +import dev.brahmkshatriya.echo.ui.playlist.delete.DeletePlaylistBottomSheet +import dev.brahmkshatriya.echo.ui.playlist.edit.EditPlaylistBottomSheet +import dev.brahmkshatriya.echo.ui.playlist.edit.EditPlaylistFragment +import dev.brahmkshatriya.echo.ui.playlist.save.SaveToPlaylistBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class MediaMoreBottomSheet : BottomSheetDialogFragment(R.layout.dialog_media_more) { + companion object { + fun newInstance( + contId: Int, + extensionId: String, + item: EchoMediaItem, + loaded: Boolean, + fromPlayer: Boolean = false, + context: EchoMediaItem? = null, + tabId: String? = null, + pos: Int? = null, + ) = MediaMoreBottomSheet().apply { + arguments = Bundle().apply { + putInt("contId", contId) + putString("extensionId", extensionId) + putSerialized("item", item) + putBoolean("loaded", loaded) + putSerialized("context", context) + putBoolean("fromPlayer", fromPlayer) + putString("tabId", tabId) + putInt("pos", pos ?: -1) + } + } + } + + private val args by lazy { requireArguments() } + private val contId by lazy { args.getInt("contId", -1).takeIf { it != -1 }!! } + private val extensionId by lazy { args.getString("extensionId")!! } + private val item by lazy { args.getSerialized("item")!!.getOrThrow() } + private val loaded by lazy { args.getBoolean("loaded") } + private val itemContext by lazy { args.getSerialized("context")?.getOrThrow() } + private val tabId by lazy { args.getString("tabId") } + private val pos by lazy { args.getInt("pos") } + private val fromPlayer by lazy { args.getBoolean("fromPlayer") } + private val delete by lazy { args.getBoolean("delete", false) } + + private val vm by viewModel { + parametersOf(false, extensionId, item, loaded, delete) + } + private val playerViewModel by activityViewModel() + + private val actionAdapter by lazy { MoreButtonAdapter() } + private val headerAdapter by lazy { + MoreHeaderAdapter({ dismiss() }, { + openItemFragment(extensionId, item, loaded) + dismiss() + }) + } + + private val loadingAdapter by lazy { + FeedLoadingAdapter(createListener { vm.refresh() }) { + val holder = LyricsItemAdapter.Loading(it) + holder + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = DialogMediaMoreBinding.bind(view) + observe(playerViewModel.playerState.current) { + headerAdapter.onCurrentChanged(it) + } + val actionFlow = + combine(vm.downloadsFlow, vm.uiResultFlow) { _, _ -> } + observe(actionFlow) { + val client = vm.extensionFlow.value?.instance?.value()?.getOrNull() + val result = vm.uiResultFlow.value?.getOrNull() + val downloads = vm.downloadsFlow.value.filter { it.download.finalFile != null } + val loaded = if (result != null) true else loaded + val list = getButtons(client, result, loaded, downloads) + actionAdapter.submitList(list) + headerAdapter.item = result?.item ?: item + } + observe(vm.itemResultFlow) { result -> + loadingAdapter.loadState = result?.map { LoadState.NotLoading(false) }?.getOrElse { + LoadState.Error(it) + } ?: LoadState.Loading + } + configureGridLayout( + binding.root, + GridAdapter.Concat( + headerAdapter, + actionAdapter, + loadingAdapter + ) + ) + } + + private fun getButtons( + client: ExtensionClient?, + state: MediaState.Loaded<*>?, + loaded: Boolean, + downloads: List + ) = getPlayerButtons() + + getPlayButtons(client, state?.item ?: item, loaded) + + getPlaylistEditButtons(client, state, loaded) + + getDownloadButtons(client, state, downloads) + + getActionButtons(state) + + getItemButtons(state?.item ?: item) + + private fun getPlayerButtons() = if (fromPlayer) listOf( + button("audio_fx", R.string.audio_fx, R.drawable.ic_equalizer) { + AudioEffectsBottomSheet().show(parentFragmentManager, null) + }, + button("sleep_timer", R.string.sleep_timer, R.drawable.ic_snooze) { + SleepTimerBottomSheet().show(parentFragmentManager, null) + }, + button("quality_selection", R.string.quality_selection, R.drawable.ic_high_quality) { + QualitySelectionBottomSheet().show(parentFragmentManager, null) + } + ) else listOf() + + private fun getPlayButtons( + client: ExtensionClient?, item: EchoMediaItem, loaded: Boolean + ) = if (client is TrackClient) listOfNotNull( + button("play", R.string.play, R.drawable.ic_play) { + playerViewModel.play(extensionId, item, loaded) + }, + if (playerViewModel.queue.isNotEmpty()) + button("next", R.string.add_to_next, R.drawable.ic_playlist_play) { + playerViewModel.addToNext(extensionId, item, loaded) + } + else null, + if (playerViewModel.queue.size > 1) + button("queue", R.string.add_to_queue, R.drawable.ic_playlist_add) { + playerViewModel.addToQueue(extensionId, item, loaded) + } + else null + ) else listOf() + + fun getPlaylistEditButtons( + client: ExtensionClient?, state: MediaState<*>?, loaded: Boolean + ) = run { + if (client !is PlaylistEditClient) return@run listOf() + val item = state?.item ?: item + val isEditable = item is Playlist && item.isEditable + listOfNotNull( + if (loaded) button( + "save_to_playlist", R.string.save_to_playlist, R.drawable.ic_library_music + ) { + SaveToPlaylistBottomSheet.newInstance(extensionId, item) + .show(parentFragmentManager, null) + } else null, + if (isEditable) button( + "edit_playlist", R.string.edit_playlist, R.drawable.ic_edit_note + ) { + openFragment( + EditPlaylistFragment.getBundle(extensionId, item, loaded) + ) + } else null, + if (isEditable) button( + "delete_playlist", R.string.delete_playlist, R.drawable.ic_delete + ) { + DeletePlaylistBottomSheet.show(requireActivity(), extensionId, item, loaded) + } else null, + if ((itemContext as? Playlist)?.isEditable == true && item is Track) button( + "remove_from_playlist", R.string.remove, R.drawable.ic_cancel + ) { + EditPlaylistBottomSheet.newInstance( + extensionId, itemContext as Playlist, tabId, pos + ).show(parentFragmentManager, null) + } else null + ) + } + + fun getDownloadButtons( + client: ExtensionClient?, state: MediaState<*>?, downloads: List + ) = run { + val item = state?.item ?: item + val shouldShowDelete = when (item) { + is Track -> downloads.any { it.download.trackId == item.id } + else -> downloads.any { it.context?.itemId == item.id } + } + val downloadable = + state != null && client is TrackClient && state.item.extras[EXTENSION_ID] != OfflineExtension.metadata.id + + listOfNotNull( + if (downloadable) button( + "download", R.string.download, R.drawable.ic_download_for_offline + ) { + val downloadViewModel by activityViewModel() + downloadViewModel.addToDownload(requireActivity(), extensionId, item, itemContext) + } else null, + if (shouldShowDelete) button( + "delete_download", R.string.delete_download, R.drawable.ic_scan_delete + ) { + val downloadViewModel by activityViewModel() + downloadViewModel.deleteDownload(item) + } else null + ) + + } + + fun getActionButtons( + state: MediaState.Loaded<*>?, + ) = listOfNotNull( + if (state?.isFollowed != null) button( + "follow", if (state.isFollowed) R.string.unfollow else R.string.follow, + if (state.isFollowed) R.drawable.ic_check_circle_filled else R.drawable.ic_check_circle + ) { + vm.followItem(!state.isFollowed) + } else null, + if (state?.isSaved != null) button( + "save_to_library", + if (state.isSaved) R.string.remove_from_library else R.string.save_to_library, + if (state.isSaved) R.drawable.ic_bookmark_filled else R.drawable.ic_bookmark_outline + ) { + vm.saveToLibrary(!state.isSaved) + } else null, + if (state?.isLiked != null) button( + "like", if (state.isLiked) R.string.unlike else R.string.like, + if (state.isLiked) R.drawable.ic_heart_filled_40dp else R.drawable.ic_heart_outline_40dp + ) { + vm.likeItem(!state.isLiked) + } else null, + if (state?.isHidden != null) button( + "hide", if (state.isHidden) R.string.unhide else R.string.hide, + if (state.isHidden) R.drawable.ic_unhide else R.drawable.ic_hide + ) { + vm.hideItem(!state.isHidden) + } else null, + if (state?.showRadio == true) button( + "radio", R.string.radio, R.drawable.ic_sensors + ) { + playerViewModel.radio(extensionId, state.item, true) + } else null, + if (state?.showShare == true) button( + "share", R.string.share, R.drawable.ic_share + ) { + vm.onShare() + } else null + ) + + private fun getItemButtons(item: EchoMediaItem) = when (item) { + is Track -> item.artists + listOfNotNull(item.album) + is EchoMediaItem.Lists -> item.artists + is Artist -> listOf() + }.map { + button(it.id, it.title, it.icon) { + openItemFragment(extensionId, it) + } + } + + private inline fun openFragment(bundle: Bundle) { + parentFragmentManager.findFragmentById(contId)!! + .openFragment(null, bundle) + } + + private fun openItemFragment( + extensionId: String?, item: EchoMediaItem?, loaded: Boolean = false + ) { + extensionId ?: return + item ?: return + openFragment(MediaFragment.getBundle(extensionId, item, loaded)) + dismiss() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButton.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButton.kt new file mode 100644 index 0000000..f94362e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButton.kt @@ -0,0 +1,29 @@ +package dev.brahmkshatriya.echo.ui.media.more + +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.DiffUtil + +data class MoreButton( + val id: String, val title: String, val icon: Int, val onClick: () -> Unit +) { + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MoreButton, newItem: MoreButton) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: MoreButton, newItem: MoreButton) = + oldItem == newItem + } + + companion object { + fun DialogFragment.button( + id: String, title: String, icon: Int, onClick: () -> Unit + ) = MoreButton(id, title, icon) { + onClick() + dismiss() + } + + fun DialogFragment.button( + id: String, title: Int, icon: Int, onClick: () -> Unit + ) = button(id, getString(title), icon, onClick) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButtonAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButtonAdapter.kt new file mode 100644 index 0000000..0115e13 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreButtonAdapter.kt @@ -0,0 +1,31 @@ +package dev.brahmkshatriya.echo.ui.media.more + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import dev.brahmkshatriya.echo.databinding.ItemMoreButtonBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class MoreButtonAdapter + : ListAdapter(MoreButton.DiffCallback), GridAdapter { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent) + override fun onBindViewHolder(holder: ViewHolder, position: Int) = + holder.bind(getItem(position)) + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = 1 + + class ViewHolder( + parent: ViewGroup, + val binding: ItemMoreButtonBinding = ItemMoreButtonBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + fun bind(item: MoreButton) = with(binding.root) { + text = item.title + setOnClickListener { item.onClick() } + setIconResource(item.icon) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreHeaderAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreHeaderAdapter.kt new file mode 100644 index 0000000..62ce2cf --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/media/more/MoreHeaderAdapter.kt @@ -0,0 +1,83 @@ +package dev.brahmkshatriya.echo.ui.media.more + +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Artist +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemMoreHeaderBinding +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.playback.PlayerState.Current.Companion.isPlaying +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.applyCover +import dev.brahmkshatriya.echo.ui.media.MediaHeaderAdapter.Companion.typeInt +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class MoreHeaderAdapter( + private val onCloseClicked: () -> Unit, + private val onItemClicked: () -> Unit +) : RecyclerView.Adapter(), GridAdapter { + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun getItemCount() = 1 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(parent) + holder.binding.run { + coverContainer.cover.clipToOutline = true + coverContainer.root.setOnClickListener { onItemClicked() } + closeButton.setOnClickListener { onCloseClicked() } + } + return holder + } + + var item: EchoMediaItem? = null + set(value) { + field = value + notifyItemChanged(0) + } + + private var viewHolder: ViewHolder? = null + override fun onBindViewHolder(holder: ViewHolder, position: Int) = with(holder.binding) { + viewHolder = holder + val item = item + holder.bind(item) + holder.onCurrentChanged(item, current) + } + + class ViewHolder( + parent: ViewGroup, + val binding: ItemMoreHeaderBinding = ItemMoreHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + + fun bind(item: EchoMediaItem?) = with(binding) { + if (item == null) return@with + title.text = item.title + type.text = when (item) { + is Artist -> "" + is EchoMediaItem.Lists -> root.context.getString(item.typeInt) + is Track -> root.context.getString(R.string.track) + } + coverContainer.run { applyCover(item, cover, listBg1, listBg2, icon) } + } + + fun onCurrentChanged(item: EchoMediaItem?, current: PlayerState.Current?) { + binding.coverContainer.isPlaying.run { + val isPlaying = current.isPlaying(item?.id) + isVisible = isPlaying + (icon as Animatable).start() + } + } + } + + var current: PlayerState.Current? = null + fun onCurrentChanged(current: PlayerState.Current?) { + this.current = current + viewHolder?.onCurrentChanged(item, current) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerColors.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerColors.kt new file mode 100644 index 0000000..5755906 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerColors.kt @@ -0,0 +1,58 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.content.Context +import android.graphics.Bitmap +import androidx.palette.graphics.Palette +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.MainActivity.Companion.isAmoled +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isNightMode + +data class PlayerColors( + val background: Int, + val accent: Int, + val onBackground: Int, +) { + companion object { + fun Context.getColorsFrom(bitmap: Bitmap?): PlayerColors? { + bitmap ?: return null + val palette = Palette.from(bitmap).generate() + return if (!isAmoled()) { + val lightMode = !isNightMode() + val lightSwatch = palette.run { + lightVibrantSwatch ?: vibrantSwatch ?: lightMutedSwatch + } + val darkSwatch = palette.run { + darkVibrantSwatch ?: darkMutedSwatch ?: mutedSwatch + } + val bgSwatch = if (lightMode) lightSwatch else darkSwatch + val accentSwatch = if (lightMode) darkSwatch else lightSwatch + bgSwatch?.run { + PlayerColors(rgb, accentSwatch?.rgb ?: titleTextColor, bodyTextColor) + } + } else defaultPlayerColors().let { default -> + val dominantColor = palette.run { + vibrantSwatch?.rgb ?: getDominantColor(0).takeIf { it != 0 } + } ?: return null + PlayerColors(default.background, dominantColor, default.onBackground) + } + } + + fun Context.defaultPlayerColors(): PlayerColors { + val background = MaterialColors.getColor( + this, R.attr.navBackground, 0 + ) + val primary = MaterialColors.getColor( + this, androidx.appcompat.R.attr.colorPrimary, 0 + ) + val onSurface = MaterialColors.getColor( + this, com.google.android.material.R.attr.colorOnSurface, 0 + ) + return PlayerColors(background, primary, onSurface) + } + + fun getDominantColor(bitmap: Bitmap): Int { + return Palette.from(bitmap).generate().getDominantColor(0) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt new file mode 100644 index 0000000..2c8aab1 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt @@ -0,0 +1,696 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.Outline +import android.graphics.drawable.Animatable +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.ProgressBar +import androidx.annotation.OptIn +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT +import androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_OUTLINE +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN +import com.google.android.material.slider.Slider +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.databinding.FragmentPlayerBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.background +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLiked +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.showBackground +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyHorizontalInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.isFinalState +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.setupPlayerMoreBehavior +import dev.brahmkshatriya.echo.ui.media.MediaFragment +import dev.brahmkshatriya.echo.ui.media.more.MediaMoreBottomSheet +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.getColorsFrom +import dev.brahmkshatriya.echo.ui.player.PlayerTrackAdapter.Companion.configureClicking +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.getDetails +import dev.brahmkshatriya.echo.ui.player.quality.QualitySelectionBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.emit +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadBlurred +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.animateVisibility +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoClearedNullable +import dev.brahmkshatriya.echo.utils.ui.CheckBoxListener +import dev.brahmkshatriya.echo.utils.ui.SimpleItemSpan +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.hideSystemUi +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isLandscape +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isRTL +import dev.brahmkshatriya.echo.utils.ui.UiUtils.marquee +import dev.brahmkshatriya.echo.utils.ui.UiUtils.toTimeString +import dev.brahmkshatriya.echo.utils.ui.ViewPager2Utils.registerOnUserPageChangeCallback +import dev.brahmkshatriya.echo.utils.ui.ViewPager2Utils.supportBottomSheetBehavior +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class PlayerFragment : Fragment() { + private var binding by autoClearedNullable() + private val viewModel by activityViewModel() + private val uiViewModel by activityViewModel() + private val adapter by lazy { + PlayerTrackAdapter(uiViewModel, viewModel.playerState.current, adapterListener) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, + ): View { + binding = FragmentPlayerBinding.inflate(inflater, container, false) + return binding!!.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = binding!! + binding.viewPager.supportBottomSheetBehavior() + setupPlayerMoreBehavior(uiViewModel, binding.playerMoreContainer) + configureOutline(binding.root) + configureCollapsing(binding) + configureColors() + configurePlayerControls() + configureBackgroundPlayerView() + } + + private val collapseHeight by lazy { + resources.getDimension(R.dimen.collapsed_cover_size).toInt() + } + + private fun configureOutline(view: View) { + val padding = 8.dpToPx(requireContext()) + var currHeight = collapseHeight + var currRound = padding.toFloat() + var currRight = 0 + var currLeft = 0 + view.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + currLeft, 0, currRight, currHeight, currRound + ) + } + } + view.clipToOutline = true + + var leftPadding = 0 + var rightPadding = 0 + + val maxElevation = 4.dpToPx(requireContext()).toFloat() + fun updateOutline() { + val offset = max(0f, uiViewModel.playerSheetOffset.value) + val inv = 1 - offset + view.elevation = maxElevation * inv + currHeight = collapseHeight + ((view.height - collapseHeight) * offset).toInt() + currLeft = (leftPadding * inv).toInt() + currRight = view.width - (rightPadding * inv).toInt() + currRound = max(padding * inv, padding * uiViewModel.playerBackProgress.value * 2) + view.invalidateOutline() + } + observe(uiViewModel.combined) { + leftPadding = (if (view.context.isRTL()) it.end else it.start) + padding + rightPadding = (if (view.context.isRTL()) it.start else it.end) + padding + updateOutline() + } + observe(uiViewModel.playerBackProgress) { updateOutline() } + observe(uiViewModel.playerSheetOffset) { updateOutline() } + view.doOnLayout { updateOutline() } + } + + private fun configureCollapsing(binding: FragmentPlayerBinding) { + binding.playerCollapsedContainer.root.clipToOutline = true + + val collapsedTopPadding = 8.dpToPx(requireContext()) + var currRound = collapsedTopPadding.toFloat() + var currTop = 0 + var currBottom = collapseHeight + var currRight = 0 + var currLeft = 0 + + val view = binding.viewPager + view.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + currLeft, currTop, currRight, currBottom, currRound + ) + } + } + view.clipToOutline = true + + val extraEndPadding = 108.dpToPx(requireContext()) + var leftPadding = 0 + var rightPadding = 0 + val isLandscape = requireContext().isLandscape() + fun updateCollapsed() { + val (collapsedY, offset, collapsedOffset) = uiViewModel.run { + if (playerSheetState.value == STATE_EXPANDED) { + val offset = moreSheetOffset.value + Triple(systemInsets.value.top, offset, if (isLandscape) 0f else offset) + } else { + val offset = 1 - max(0f, playerSheetOffset.value) + Triple(-collapsedTopPadding, offset, offset) + } + } + val collapsedInv = 1 - collapsedOffset + binding.playerCollapsedContainer.root.run { + translationY = collapsedY - collapseHeight * collapsedInv * 2 + alpha = collapsedOffset * 2 + translationZ = -1f * collapsedInv + } + binding.bgCollapsed.run { + translationY = collapsedY - collapseHeight * collapsedInv * 2 + alpha = min(1f, collapsedOffset * 2) - 0.5f + } + val alphaInv = 1 - min(1f, offset * 3) + binding.expandedToolbar.run { + translationY = collapseHeight * offset * 2 + alpha = alphaInv + isVisible = offset < 1 + translationZ = -1f * offset + } + binding.playerControls.root.run { + translationY = collapseHeight * offset * 2 + alpha = alphaInv + isVisible = offset < 1 + } + currTop = uiViewModel.run { + val top = if (playerSheetState.value != STATE_EXPANDED) 0 + else collapsedTopPadding + systemInsets.value.top + (top * max(0f, (collapsedOffset - 0.75f) * 4)).toInt() + } + val bot = currTop + collapseHeight + currBottom = bot + ((view.height - bot) * collapsedInv).toInt() + currLeft = (leftPadding * collapsedOffset).toInt() + currRight = view.width - (rightPadding * collapsedOffset).toInt() + currRound = collapsedTopPadding * collapsedOffset + view.invalidateOutline() + } + + view.doOnLayout { updateCollapsed() } + observe(uiViewModel.combined) { + val system = uiViewModel.systemInsets.value + binding.constraintLayout.applyInsets(system, 64, 0) + binding.expandedToolbar.applyInsets(system) + val insets = uiViewModel.run { + if (playerSheetState.value == STATE_EXPANDED) system + else getCombined() + } + binding.playerCollapsedContainer.root.applyHorizontalInsets(insets) + binding.playerControls.root.applyHorizontalInsets( + insets, + requireActivity().isLandscape() + ) + val left = if (requireContext().isRTL()) system.end + extraEndPadding else system.start + leftPadding = collapsedTopPadding + left + val right = if (requireContext().isRTL()) system.start else system.end + extraEndPadding + rightPadding = collapsedTopPadding + right + updateCollapsed() + adapter.insetsUpdated() + } + + observe(uiViewModel.moreSheetOffset) { + updateCollapsed() + adapter.moreOffsetUpdated() + } + observe(uiViewModel.playerSheetOffset) { + updateCollapsed() + adapter.playerOffsetUpdated() + + viewModel.browser.value?.volume = 1 + min(0f, it) + if (it < 1) + requireActivity().hideSystemUi(false) + else if (uiViewModel.playerBgVisible.value) + requireActivity().hideSystemUi(true) + } + + observe(uiViewModel.playerSheetState) { + updateCollapsed() + if (isFinalState(it)) adapter.playerSheetStateUpdated() + if (it == STATE_HIDDEN) viewModel.clearQueue() + else if (it == STATE_COLLAPSED) emit(uiViewModel.playerBgVisible, false) + } + + binding.playerControls.root.doOnLayout { + uiViewModel.playerControlsHeight.value = it.height + adapter.playerControlsHeightUpdated() + } + observe(uiViewModel.playerBgVisible) { + binding.fgContainer.animateVisibility(!it) + binding.playerMoreContainer.animateVisibility(!it) + requireActivity().hideSystemUi(it) + } + binding.bgPanel.configureClicking(adapterListener, uiViewModel) + binding.playerCollapsedContainer.playerClose.setOnClickListener { + uiViewModel.changePlayerState(STATE_HIDDEN) + } + binding.expandedToolbar.setNavigationOnClickListener { + uiViewModel.collapsePlayer() + } + } + + private val adapterListener = object : PlayerTrackAdapter.Listener { + override fun onClick() = uiViewModel.run { + if (playerSheetState.value != STATE_EXPANDED) changePlayerState(STATE_EXPANDED) + else { + if (moreSheetState.value == STATE_EXPANDED) { + changeMoreState(STATE_COLLAPSED) + return + } + val shouldBeVisible = !playerBgVisible.value + if (shouldBeVisible) { + val binding = binding ?: return@run + if (binding.bgImage.drawable == null && !binding.playerView.player.hasVideo()) + return + changeMoreState(STATE_COLLAPSED) + } + changeBgVisible(shouldBeVisible) + } + } + + override fun onStartDoubleClick() { + viewModel.seekToAdd(-10000) + } + + override fun onEndDoubleClick() { + viewModel.seekToAdd(10000) + } + } + + private fun configurePlayerControls() { + val viewPager = binding!!.viewPager + viewPager.adapter = adapter + viewPager.registerOnUserPageChangeCallback { pos, isUser -> + val index = viewModel.playerState.current.value?.index + if (index != pos && isUser) viewModel.seek(pos) + } + + fun submit() { + adapter.submitList(viewModel.queue) { + val index = (viewModel.playerState.current.value?.index ?: -1).takeIf { it != -1 } + ?: return@submitList + val current = binding?.viewPager?.currentItem ?: 0 + val smooth = abs(index - current) <= 1 + binding?.viewPager?.setCurrentItem(index, smooth) + } + } + + val binding = binding!! + binding.playerControls.trackHeart.addOnCheckedStateChangedListener(likeListener) + observe(viewModel.playerState.current) { + uiViewModel.run { + if (it == null) return@run changePlayerState(STATE_HIDDEN) + if (!isFinalState(playerSheetState.value)) return@run + changePlayerState( + if (playerSheetState.value != STATE_EXPANDED) STATE_COLLAPSED + else STATE_EXPANDED + ) + } + submit() + it?.mediaItem ?: return@observe + binding.applyCurrent(it.mediaItem) + } + + observe(viewModel.queueFlow) { submit() } + + val playPauseListener = CheckBoxListener { viewModel.setPlaying(it) } + binding.playerControls.trackPlayPause + .addOnCheckedStateChangedListener(playPauseListener) + binding.playerCollapsedContainer.collapsedTrackPlayPause + .addOnCheckedStateChangedListener(playPauseListener) + observe(viewModel.isPlaying) { + binding.run { + playPauseListener.enabled = false + playerControls.trackPlayPause.isChecked = it + playerCollapsedContainer.collapsedTrackPlayPause.isChecked = it + playPauseListener.enabled = true + } + } + observe(viewModel.buffering) { + binding.playerControls.playingIndicator.alpha = if (it) 1f else 0f + binding.playerCollapsedContainer.collapsedPlayingIndicator.alpha = if (it) 1f else 0f + } + + observe(viewModel.progress) { (curr, buff) -> + binding.playerCollapsedContainer.run { + collapsedBuffer.progress = buff.toInt() + collapsedSeekbar.progress = curr.toInt() + } + binding.playerControls.run { + if (!seekBar.isPressed) { + bufferBar.progress = buff.toInt() + seekBar.value = max(0f, min(curr.toFloat(), seekBar.valueTo)) + trackCurrentTime.text = curr.toTimeString() + } + } + } + + observe(viewModel.totalDuration) { + val duration = it ?: viewModel.playerState.current.value?.track?.duration ?: 0 + binding.playerCollapsedContainer.run { + collapsedSeekbar.max = duration.toInt() + collapsedBuffer.max = duration.toInt() + } + binding.playerControls.run { + bufferBar.max = duration.toInt() + seekBar.apply { + value = max(0f, min(value, duration.toFloat())) + valueTo = 1f + duration + } + trackTotalTime.text = duration.toTimeString() + } + } + + + val repeatModes = listOf(REPEAT_MODE_OFF, REPEAT_MODE_ALL, REPEAT_MODE_ONE) + val animatedVectorDrawables = requireContext().run { + fun asAnimated(id: Int) = + AppCompatResources.getDrawable(this, id) as AnimatedVectorDrawable + listOf( + asAnimated(R.drawable.ic_repeat_one_to_repeat_off_40dp), + asAnimated(R.drawable.ic_repeat_off_to_repeat_40dp), + asAnimated(R.drawable.ic_repeat_to_repeat_one_40dp) + ) + } + val drawables = requireContext().run { + fun asDrawable(id: Int) = AppCompatResources.getDrawable(this, id)!! + listOf( + asDrawable(R.drawable.ic_repeat_off_40dp), + asDrawable(R.drawable.ic_repeat_40dp), + asDrawable(R.drawable.ic_repeat_one_40dp), + ) + } + + binding.playerControls.trackRepeat.icon = + drawables[repeatModes.indexOf(viewModel.repeatMode.value)] + + fun changeRepeatDrawable(repeatMode: Int) = binding.playerControls.trackRepeat.run { + val index = repeatModes.indexOf(repeatMode) + icon = animatedVectorDrawables[index] + (icon as Animatable).start() + } + + binding.playerControls.run { + seekBar.apply { + addOnChangeListener { _, value, fromUser -> + if (fromUser) trackCurrentTime.text = value.toLong().toTimeString() + } + addOnSliderTouchListener(object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) = Unit + override fun onStopTrackingTouch(slider: Slider) = + viewModel.seekTo(slider.value.toLong()) + }) + } + + trackNext.setOnClickListener { + viewModel.next() + (trackNext.icon as Animatable).start() + } + observe(viewModel.nextEnabled) { trackNext.isEnabled = it } + + trackPrevious.setOnClickListener { + viewModel.previous() + (trackPrevious.icon as Animatable).start() + } + observe(viewModel.previousEnabled) { trackPrevious.isEnabled = it } + + val shuffleListener = CheckBoxListener { viewModel.setShuffle(it) } + trackShuffle.addOnCheckedStateChangedListener(shuffleListener) + observe(viewModel.shuffleMode) { + shuffleListener.enabled = false + trackShuffle.isChecked = it + shuffleListener.enabled = true + } + + trackRepeat.setOnClickListener { + val mode = when (viewModel.repeatMode.value) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + else -> REPEAT_MODE_OFF + } + changeRepeatDrawable(mode) + viewModel.setRepeat(mode) + } + observe(viewModel.repeatMode) { changeRepeatDrawable(it) } + + trackSubtitle.setOnClickListener { + QualitySelectionBottomSheet().show(parentFragmentManager, null) + } + observe(viewModel.serverAndTracks) { (tracks, server, index) -> + trackSubtitle.text = tracks?.getDetails(requireContext(), server, index) + ?.joinToString(" ⦿ ")?.takeIf { it.isNotBlank() } + } + } + } + + private val likeListener = CheckBoxListener { viewModel.likeCurrent(it) } + + private fun configureColors() { + observe(viewModel.playerState.current) { adapter.onCurrentUpdated() } + var last: Drawable? = null + adapter.currentDrawableListener = { drawable -> + if (last != drawable) { + last = drawable + val context = requireContext() + uiViewModel.playerDrawable.value = drawable + val colors = + if (context.isDynamic()) context.getColorsFrom(drawable?.toBitmap()) else null + uiViewModel.playerColors.value = colors + if (context.showBackground()) binding?.bgImage?.loadBlurred(drawable, 12f) + else binding?.bgImage?.setImageDrawable(null) + } + } + val bufferView = + binding?.playerView?.findViewById(androidx.media3.ui.R.id.exo_buffering) + observe(uiViewModel.playerColors) { + val context = requireContext() + if (context.isPlayerColor() && context.isDynamic()) { + if (uiViewModel.currentAppColor != viewModel.playerState.current.value?.track?.id) { + uiViewModel.currentAppColor = + viewModel.playerState.current.value?.track?.id + requireActivity().recreate() + return@observe + } + } + val colors = it ?: context.defaultPlayerColors() + val binding = binding!! + adapter.onColorsUpdated() + + binding.run { + val color = if (requireContext().isDynamic()) colors.accent + else colors.background + root.setBackgroundColor(color) + val backgroundState = ColorStateList.valueOf(colors.background) + bgGradient.imageTintList = backgroundState + bgCollapsed.backgroundTintList = backgroundState + bufferView?.indeterminateDrawable?.setTint(colors.accent) + expandedToolbar.run { + setTitleTextColor(colors.onBackground) + setSubtitleTextColor(colors.onBackground) + } + } + + binding.playerCollapsedContainer.run { + collapsedPlayingIndicator.setIndicatorColor(colors.accent) + collapsedSeekbar.setIndicatorColor(colors.accent) + collapsedBuffer.setIndicatorColor(colors.accent) + collapsedBuffer.trackColor = colors.onBackground + } + + binding.playerControls.run { + seekBar.trackActiveTintList = ColorStateList.valueOf(colors.accent) + seekBar.thumbTintList = ColorStateList.valueOf(colors.accent) + playingIndicator.setIndicatorColor(colors.accent) + bufferBar.setIndicatorColor(colors.accent) + bufferBar.trackColor = colors.onBackground + trackCurrentTime.setTextColor(colors.onBackground) + trackTotalTime.setTextColor(colors.onBackground) + trackTitle.setTextColor(colors.onBackground) + trackArtist.setTextColor(colors.onBackground) + } + } + } + + private fun FragmentPlayerBinding.applyCurrent(item: MediaItem) { + val track = item.track + val extId = item.extensionId + expandedToolbar.run { + val itemContext = item.context + title = if (itemContext != null) context.getString(R.string.playing_from) else null + subtitle = itemContext?.title + setOnMenuItemClickListener { + if (it.itemId != R.id.menu_more) return@setOnMenuItemClickListener false + onMoreClicked(item) + true + } + } + playerControls.run { + trackTitle.text = track.title + trackTitle.marquee() + val artists = track.artists + val artistNames = artists.joinToString(", ") { it.name } + val span = SpannableString(artistNames) + + artists.forEach { artist -> + val start = artistNames.indexOf(artist.name) + val end = start + artist.name.length + val clickableSpan = SimpleItemSpan(trackArtist.context) { + openItem(extId, artist) + } + runCatching { + span.setSpan( + clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + trackArtist.text = span + trackArtist.movementMethod = LinkMovementMethod.getInstance() + likeListener.enabled = false + trackHeart.isChecked = item.isLiked + likeListener.enabled = true + lifecycleScope.launch { + val isTrackClient = viewModel.isLikeClient(item.extensionId) + trackHeart.isVisible = isTrackClient + } + } + } + + private fun openItem(extension: String, item: EchoMediaItem) { + requireActivity().openFragment( + null, MediaFragment.getBundle(extension, item, false) + ) + } + + private fun onMoreClicked(item: MediaItem) { + MediaMoreBottomSheet.newInstance( + R.id.navHostFragment, item.extensionId, item.track, item.isLoaded, true + ).show(requireActivity().supportFragmentManager, null) + } + + private fun Player?.hasVideo() = + this?.currentTracks?.groups.orEmpty().any { it.type == C.TRACK_TYPE_VIDEO } + + private fun applyVideoVisibility(visible: Boolean) { + binding?.playerView?.isVisible = visible + binding?.bgImage?.isVisible = !visible + if (requireContext().isLandscape()) return + binding?.playerControls?.trackCoverPlaceHolder?.isVisible = visible + adapter.updatePlayerVisibility(visible) + } + + private var oldBg: Streamable.Media.Background? = null + private var backgroundPlayer: Player? = null + + @OptIn(UnstableApi::class) + private fun applyPlayer() { + val mainPlayer = viewModel.browser.value + val background = viewModel.playerState.current.value?.mediaItem?.background + val visible = if (mainPlayer.hasVideo()) { + binding?.playerView?.player = mainPlayer + binding?.playerView?.resizeMode = RESIZE_MODE_FIT + backgroundPlayer?.release() + backgroundPlayer = null + true + } else if (background != null) { + if (oldBg != background || backgroundPlayer == null) { + oldBg = background + backgroundPlayer?.release() + backgroundPlayer = getPlayer(requireContext(), viewModel.cache, background) + } + binding?.playerView?.player = backgroundPlayer + binding?.playerView?.resizeMode = RESIZE_MODE_ZOOM + true + } else { + backgroundPlayer?.release() + backgroundPlayer = null + binding?.playerView?.player = null + false + } + applyVideoVisibility(visible) + } + + @OptIn(UnstableApi::class) + private fun configureBackgroundPlayerView() { + binding?.playerView?.subtitleView?.setStyle( + CaptionStyleCompat( + Color.WHITE, Color.TRANSPARENT, Color.TRANSPARENT, + EDGE_TYPE_OUTLINE, Color.BLACK, null + ) + ) + observe(viewModel.serverAndTracks) { applyPlayer() } + } + + companion object { + private fun Context.showBackground() = getSettings().showBackground() + const val DYNAMIC_PLAYER = "dynamic_player" + const val PLAYER_COLOR = "player_app_color" + fun Context.isDynamic() = + getSettings().getBoolean(DYNAMIC_PLAYER, true) + + private fun Context.isPlayerColor() = + getSettings().getBoolean(PLAYER_COLOR, false) + + @OptIn(UnstableApi::class) + fun getPlayer( + context: Context, cache: SimpleCache, video: Streamable.Media.Background, + ): ExoPlayer { + val cacheFactory = CacheDataSource + .Factory().setCache(cache) + .setUpstreamDataSourceFactory( + DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(video.request.headers) + ) + val factory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(cacheFactory) + val player = ExoPlayer.Builder(context).setMediaSourceFactory(factory).build() + player.setMediaItem(MediaItem.fromUri(video.request.url.toUri())) + player.repeatMode = REPEAT_MODE_ONE + player.volume = 0f + player.prepare() + player.play() + return player + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt new file mode 100644 index 0000000..8155eab --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt @@ -0,0 +1,261 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.graphics.Outline +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.doOnLayout +import androidx.core.view.updateLayoutParams +import androidx.media3.common.MediaItem +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.ItemClickPanelsBinding +import dev.brahmkshatriya.echo.databinding.ItemPlayerTrackBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.unloadedCover +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyHorizontalInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors +import dev.brahmkshatriya.echo.utils.image.ImageUtils.getCachedDrawable +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadWithThumb +import dev.brahmkshatriya.echo.utils.ui.GestureListener +import dev.brahmkshatriya.echo.utils.ui.GestureListener.Companion.handleGestures +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isLandscape +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isRTL +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.math.max + +class PlayerTrackAdapter( + private val uiViewModel: UiViewModel, + private val current: MutableStateFlow, + private val listener: Listener +) : ListAdapter(DiffCallback) { + + interface Listener { + fun onClick() + fun onLongClick() {} + fun onStartDoubleClick() {} + fun onEndDoubleClick() {} + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MediaItem, newItem: MediaItem) = + oldItem.mediaId == newItem.mediaId + + override fun areContentsTheSame(oldItem: MediaItem, newItem: MediaItem): Boolean { + return oldItem == newItem + } + } + + inner class ViewHolder( + private val binding: ItemPlayerTrackBinding + ) : ScrollAnimViewHolder(binding.root) { + + private val context = binding.root.context + + private val collapsedPadding = 8.dpToPx(context) + private val targetZ = collapsedPadding.toFloat() + private val size = binding.root.resources.getDimension(R.dimen.collapsed_cover_size).toInt() + private var targetScale = 0f + private var targetX = 0 + private var targetY = 0 + + private val cover = binding.playerTrackCoverContainer + private var currentCoverHeight = size + private var currCoverRound = 0f + private val isLandscape = context.isLandscape() + fun updateCollapsed() = uiViewModel.run { + val insets = if (!isLandscape) systemInsets.value else getCombined() + val targetPosX = collapsedPadding + if (context.isRTL()) insets.end else insets.start + val targetPosY = if (playerSheetState.value != STATE_EXPANDED) 0 + else collapsedPadding + systemInsets.value.top + targetX = targetPosX - cover.left + targetY = targetPosY - cover.top + currentCoverHeight = cover.height.takeIf { it > 0 } ?: currentCoverHeight + targetScale = size.toFloat() / currentCoverHeight + + val (collapsedY, offset) = if (playerSheetState.value == STATE_EXPANDED) + systemInsets.value.top to if (isLandscape) 0f else moreSheetOffset.value + else -collapsedPadding to 1 - max(0f, playerSheetOffset.value) + + val inv = 1 - offset + binding.playerCollapsed.root.run { + translationY = collapsedY - size * inv * 2 + alpha = offset + } + if (isLandscape) binding.clickPanel.root.scaleX = 0.5f + 0.5f * inv + val extraY = if (!isPlayerVisible) 0f else { + val toMoveY = binding.playerControlsPlaceholder.top - cover.top + toMoveY * inv + } + val extraX = if (!isPlayerVisible) 0f else { + val toMoveX = binding.playerControlsPlaceholder.left - cover.left + toMoveX * inv + } + cover.run { + scaleX = if (!isPlayerVisible) 1 + (targetScale - 1) * offset else targetScale + scaleY = scaleX + translationX = targetX * offset + extraX + translationY = targetY * offset + extraY + translationZ = targetZ * (1 - offset) + currCoverRound = collapsedPadding / scaleX + invalidateOutline() + } + } + + fun updateInsets() = uiViewModel.run { + val (v, h) = if (!isLandscape) 64 to 0 else 0 to 24 + binding.constraintLayout.applyInsets(systemInsets.value, v, h) + val insets = if (isLandscape) getCombined() else systemInsets.value + binding.playerCollapsed.root.applyHorizontalInsets(insets) + binding.playerControlsPlaceholder.run { + updateLayoutParams { + height = playerControlsHeight.value + } + doOnLayout { + updateCollapsed() + cover.doOnLayout { updateCollapsed() } + } + } + + updateCollapsed() + } + + fun updateColors() { + binding.playerCollapsed.run { + val colors = uiViewModel.playerColors.value ?: context.defaultPlayerColors() + collapsedTrackTitle.setTextColor(colors.onBackground) + collapsedTrackArtist.setTextColor(colors.onBackground) + } + } + + private var coverDrawable: Drawable? = null + fun applyDrawable() { + val index = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return + val item = getItem(index) ?: return + val curr = current.value?.mediaItem + if (curr != item) return + val drawable = coverDrawable + currentDrawableListener?.invoke(drawable) + } + + fun bind(item: MediaItem?) { + binding.playerCollapsed.run { + collapsedTrackTitle.text = item?.track?.title + collapsedTrackArtist.text = item?.track?.artists?.joinToString(", ") { it.name } + } + val old = item?.unloadedCover?.getCachedDrawable(binding.root.context) + item?.track?.cover.loadWithThumb(binding.playerTrackCover, old) { + val image = it + ?: ResourcesCompat.getDrawable(resources, R.drawable.art_music, context.theme) + setImageDrawable(image) + coverDrawable = it + applyDrawable() + } + updateInsets() + updateColors() + } + + init { + cover.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + 0, 0, currentCoverHeight, currentCoverHeight, currCoverRound + ) + } + } + cover.clipToOutline = true + cover.doOnLayout { updateInsets() } + binding.clickPanel.configureClicking(listener, uiViewModel) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemPlayerTrackBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = null + } + + override fun onViewAttachedToWindow(holder: ViewHolder) { + holder.updateInsets() + holder.updateColors() + holder.applyDrawable() + } + + override fun onViewDetachedFromWindow(holder: ViewHolder) { + holder.updateInsets() + holder.updateColors() + holder.applyDrawable() + } + + private fun onEachViewHolder(block: ViewHolder.() -> Unit) { + val recyclerView = recyclerView ?: return + recyclerView.run { + for (it in 0 until childCount) { + val viewHolder = getChildViewHolder(getChildAt(it)) as? ViewHolder + viewHolder?.block() + } + } + } + + fun moreOffsetUpdated() = onEachViewHolder { updateCollapsed() } + fun playerOffsetUpdated() = onEachViewHolder { updateCollapsed() } + fun playerSheetStateUpdated() = onEachViewHolder { updateInsets() } + fun insetsUpdated() = onEachViewHolder { updateInsets() } + fun playerControlsHeightUpdated() = onEachViewHolder { updateInsets() } + fun onColorsUpdated() = onEachViewHolder { updateColors() } + fun onCurrentUpdated() { + onEachViewHolder { applyDrawable() } + if (current.value == null) currentDrawableListener?.invoke(null) + } + + private var isPlayerVisible = false + fun updatePlayerVisibility(visible: Boolean) { + isPlayerVisible = visible + onEachViewHolder { updateInsets() } + } + + var currentDrawableListener: ((Drawable?) -> Unit)? = null + + companion object { + fun ItemClickPanelsBinding.configureClicking(listener: Listener, uiViewModel: UiViewModel) { + start.handleGestures(object : GestureListener { + override val onClick = listener::onClick + override val onLongClick = listener::onLongClick + override val onDoubleClick: (() -> Unit)? + get() = if (uiViewModel.playerSheetState.value != STATE_EXPANDED) null + else listener::onStartDoubleClick + }) + end.handleGestures(object : GestureListener { + override val onClick = listener::onClick + override val onLongClick = listener::onLongClick + override val onDoubleClick: (() -> Unit)? + get() = if (uiViewModel.playerSheetState.value != STATE_EXPANDED) null + else listener::onEndDoubleClick + }) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt new file mode 100644 index 0000000..97b178b --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt @@ -0,0 +1,118 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.viewModelScope +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.launch + +class PlayerUiListener( + private val player: Player, + private val viewModel: PlayerViewModel +) : Player.Listener { + + init { + updateList() + with(viewModel) { + tracksFlow.value = player.currentTracks + isPlaying.value = player.isPlaying + buffering.value = player.playbackState == Player.STATE_BUFFERING + shuffleMode.value = player.shuffleModeEnabled + repeatMode.value = player.repeatMode + } + updateNavigation() + } + + private fun updateList() = viewModel.run { + updateNavigation() + queue = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } + viewModelScope.launch { queueFlow.emit(Unit) } + } + + private fun updateNavigation() { + viewModel.nextEnabled.value = player.hasNextMediaItem() + viewModel.previousEnabled.value = player.currentMediaItemIndex >= 0 + } + + private val delay = 500L + private val threshold = 0.2f + private val updateProgressRunnable = Runnable { updateProgress() } + private val handler = Handler(Looper.getMainLooper()).also { + it.post(updateProgressRunnable) + } + + private fun updateProgress() { + viewModel.progress.value = + player.currentPosition to player.bufferedPosition + viewModel.totalDuration.value = player.duration.takeIf { it != TIME_UNSET } + + handler.removeCallbacks(updateProgressRunnable) + val playbackState = player.playbackState + if (playbackState != ExoPlayer.STATE_IDLE && playbackState != ExoPlayer.STATE_ENDED) { + var delayMs: Long + if (player.playWhenReady && playbackState == ExoPlayer.STATE_READY) { + delayMs = delay - player.currentPosition % delay + if (delayMs < delay * threshold) { + delayMs += delay + } + } else { + delayMs = delay + } + handler.postDelayed(updateProgressRunnable, delayMs) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> + viewModel.buffering.value = true + + Player.STATE_READY -> { + viewModel.buffering.value = false + } + + else -> Unit + } + updateProgress() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + viewModel.isPlaying.value = isPlaying + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int + ) { + updateNavigation() + updateProgress() + viewModel.discontinuity.value = newPosition.positionMs + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateList() + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + viewModel.shuffleMode.value = player.shuffleModeEnabled + updateList() + } + + override fun onRepeatModeChanged(repeatMode: Int) { + viewModel.repeatMode.value = player.repeatMode + updateList() + } + + override fun onPlayerError(error: PlaybackException) { + viewModel.isPlaying.value = false + viewModel.buffering.value = false + } + + override fun onTracksChanged(tracks: Tracks) { + viewModel.tracksFlow.value = tracks + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000..8a861ed --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerViewModel.kt @@ -0,0 +1,315 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.os.bundleOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.ThumbRating +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.LikeClient +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverWithDownloads +import dev.brahmkshatriya.echo.playback.MediaItemUtils.sourceIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.PlayerCommands.addToNextCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.addToQueueCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.playCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.radioCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.resumeCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.sleepTimer +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.getController +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.utils.ContextUtils.listenFuture +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.max + +@OptIn(UnstableApi::class) +class PlayerViewModel( + val app: App, + val playerState: PlayerState, + val settings: SharedPreferences, + val cache: SimpleCache, + val extensions: ExtensionLoader, + downloader: Downloader, +) : ViewModel() { + private val downloadFlow = downloader.flow + + val browser = MutableStateFlow(null) + private fun withBrowser(block: suspend (MediaController) -> Unit) { + viewModelScope.launch { + val browser = browser.first { it != null }!! + block(browser) + } + } + + var queue: List = emptyList() + val queueFlow = MutableSharedFlow() + private val context = app.context + val controllerFutureRelease = getController(context) { player -> + browser.value = player + player.addListener(PlayerUiListener(player, this)) + + if (player.mediaItemCount != 0) return@getController + if (!settings.getBoolean(KEEP_QUEUE, true)) return@getController + + player.sendCustomCommand(resumeCommand, Bundle.EMPTY) + } + + override fun onCleared() { + super.onCleared() + controllerFutureRelease() + } + + fun play(position: Int) { + withBrowser { + it.seekTo(position, 0) + it.playWhenReady = true + } + } + + fun seek(position: Int) { + withBrowser { it.seekTo(position, 0) } + } + + fun removeQueueItem(position: Int) { + withBrowser { it.removeMediaItem(position) } + } + + fun moveQueueItems(fromPos: Int, toPos: Int) { + withBrowser { it.moveMediaItem(fromPos, toPos) } + } + + fun clearQueue() { + withBrowser { it.clearMediaItems() } + } + + fun seekTo(pos: Long) { + withBrowser { it.seekTo(pos) } + } + + fun seekToAdd(position: Int) { + withBrowser { it.seekTo(max(0, it.currentPosition + position)) } + } + + fun setPlaying(isPlaying: Boolean) { + withBrowser { + it.prepare() + it.playWhenReady = isPlaying + } + } + + fun next() { + withBrowser { it.seekToNextMediaItem() } + } + + fun previous() { + withBrowser { it.seekToPrevious() } + } + + fun setShuffle(isShuffled: Boolean, changeCurrent: Boolean = false) { + withBrowser { + it.shuffleModeEnabled = isShuffled + if (changeCurrent) it.seekTo(0, 0) + } + } + + fun setRepeat(repeatMode: Int) { + withBrowser { it.repeatMode = repeatMode } + } + + suspend fun isLikeClient(extensionId: String): Boolean = withContext(Dispatchers.IO) { + extensions.music.getExtension(extensionId)?.isClient() ?: false + } + + private fun createException(throwable: Throwable) { + viewModelScope.launch { app.throwFlow.emit(throwable) } + } + + fun likeCurrent(isLiked: Boolean) = withBrowser { controller -> + val future = controller.setRating(ThumbRating(isLiked)) + app.context.listenFuture(future) { sessionResult -> + sessionResult.getOrElse { createException(it) } + } + } + + fun setSleepTimer(timer: Long) { + withBrowser { it.sendCustomCommand(sleepTimer, bundleOf("ms" to timer)) } + } + + fun changeTrackSelection(trackGroup: TrackGroup, index: Int) { + withBrowser { + it.trackSelectionParameters = it.trackSelectionParameters + .buildUpon() + .clearOverride(trackGroup) + .addOverride(TrackSelectionOverride(trackGroup, index)) + .build() + } + } + + private fun changeCurrent(newItem: MediaItem) { + withBrowser { player -> + val oldPosition = player.currentPosition + player.replaceMediaItem(player.currentMediaItemIndex, newItem) + player.prepare() + player.seekTo(oldPosition) + } + } + + fun changeServer(server: Streamable) { + val item = playerState.current.value?.mediaItem ?: return + val index = item.serverWithDownloads(app.context).indexOf(server).takeIf { it != -1 } + ?: return + changeCurrent(MediaItemUtils.buildServer(item, index)) + } + + fun changeBackground(background: Streamable?) { + val item = playerState.current.value?.mediaItem ?: return + val index = item.track.backgrounds.indexOf(background) + changeCurrent(MediaItemUtils.buildBackground(item, index)) + } + + fun changeSubtitle(subtitle: Streamable?) { + val item = playerState.current.value?.mediaItem ?: return + val index = item.track.subtitles.indexOf(subtitle) + changeCurrent(MediaItemUtils.buildSubtitle(item, index)) + } + + fun changeCurrentSource(index: Int) { + val item = playerState.current.value?.mediaItem ?: return + changeCurrent(MediaItemUtils.buildSource(item, index)) + } + + fun setQueue(id: String, list: List, index: Int, context: EchoMediaItem?) { + withBrowser { controller -> + val mediaItems = list.map { + MediaItemUtils.build( + app, + downloadFlow.value, + MediaState.Unloaded(id, it), + context + ) + } + controller.setMediaItems(mediaItems, index, list[index].playedDuration ?: 0) + controller.prepare() + } + } + + fun radio(id: String, item: EchoMediaItem, loaded: Boolean) = viewModelScope.launch { + app.messageFlow.emit( + Message(app.context.getString(R.string.loading_radio_for_x, item.title)) + ) + withBrowser { + it.sendCustomCommand(radioCommand, Bundle().apply { + putString("extId", id) + putSerialized("item", item) + putBoolean("loaded", loaded) + }) + } + } + + fun play(id: String, item: EchoMediaItem, loaded: Boolean) = viewModelScope.launch { + if (item !is Track) app.messageFlow.emit( + Message(app.context.getString(R.string.playing_x, item.title)) + ) + withBrowser { + it.sendCustomCommand(playCommand, Bundle().apply { + putString("extId", id) + putSerialized("item", item) + putBoolean("loaded", loaded) + putBoolean("shuffle", false) + }) + } + } + + fun shuffle(id: String, item: EchoMediaItem, loaded: Boolean) = viewModelScope.launch { + if (item !is Track) app.messageFlow.emit( + Message(app.context.getString(R.string.shuffling_x, item.title)) + ) + withBrowser { + it.sendCustomCommand(playCommand, Bundle().apply { + putString("extId", id) + putSerialized("item", item) + putBoolean("loaded", loaded) + putBoolean("shuffle", true) + }) + } + } + + + fun addToQueue(id: String, item: EchoMediaItem, loaded: Boolean) = viewModelScope.launch { + if (item !is Track) app.messageFlow.emit( + Message(app.context.getString(R.string.adding_x_to_queue, item.title)) + ) + withBrowser { + it.sendCustomCommand(addToQueueCommand, Bundle().apply { + putString("extId", id) + putSerialized("item", item) + putBoolean("loaded", loaded) + }) + } + } + + fun addToNext(id: String, item: EchoMediaItem, loaded: Boolean) = viewModelScope.launch { + if (!(browser.value?.mediaItemCount == 0 && item is Track)) app.messageFlow.emit( + Message(app.context.getString(R.string.adding_x_to_next, item.title)) + ) + withBrowser { + it.sendCustomCommand(addToNextCommand, Bundle().apply { + putString("extId", id) + putSerialized("item", item) + putBoolean("loaded", loaded) + }) + } + } + + val progress = MutableStateFlow(0L to 0L) + val discontinuity = MutableStateFlow(0L) + val totalDuration = MutableStateFlow(null) + + val buffering = MutableStateFlow(false) + val isPlaying = MutableStateFlow(false) + val nextEnabled = MutableStateFlow(false) + val previousEnabled = MutableStateFlow(false) + val repeatMode = MutableStateFlow(0) + val shuffleMode = MutableStateFlow(false) + + val tracksFlow = MutableStateFlow(null) + val serverAndTracks = tracksFlow.combine(playerState.serverChanged) { tracks, _ -> tracks } + .combine(playerState.current) { tracks, current -> + val server = playerState.servers[current?.mediaItem?.mediaId]?.getOrNull() + val index = current?.mediaItem?.sourceIndex + Triple(tracks, server, index) + }.stateIn(viewModelScope, SharingStarted.Lazily, Triple(null, null, null)) + + companion object { + const val KEEP_QUEUE = "keep_queue" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/audiofx/AudioEffectsBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/audiofx/AudioEffectsBottomSheet.kt new file mode 100644 index 0000000..4f3ee72 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/audiofx/AudioEffectsBottomSheet.kt @@ -0,0 +1,134 @@ +package dev.brahmkshatriya.echo.ui.player.audiofx + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.media.audiofx.AudioEffect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.viewModelScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.DialogPlayerAudioFxBinding +import dev.brahmkshatriya.echo.databinding.FragmentAudioFxBinding +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.BASS_BOOST +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.CHANGE_PITCH +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.CUSTOM_EFFECTS +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.PLAYBACK_SPEED +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.deleteFxPrefs +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.getFxPrefs +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.globalFx +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.speedRange +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.PermsUtils.registerActivityResultLauncher +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.RulerAdapter +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class AudioEffectsBottomSheet : BottomSheetDialogFragment() { + + var binding by autoCleared() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = DialogPlayerAudioFxBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val viewModel by activityViewModel() + var mediaId: String? = null + + fun bind() { + val settings = requireContext().globalFx() + settings.edit { + val customEffects = settings.getStringSet(CUSTOM_EFFECTS, null) ?: emptySet() + putStringSet(CUSTOM_EFFECTS, customEffects + mediaId?.hashCode()?.toString()) + } + binding.audioFxDescription.isVisible = mediaId != null + val mediaSettings = + requireContext().getFxPrefs(settings, mediaId?.hashCode()) ?: settings + binding.audioFxFragment.bind(mediaSettings) { onEqualizerClicked() } + } + observe(viewModel.playerState.current) { + mediaId = it?.mediaItem?.mediaId + bind() + } + binding.topAppBar.setNavigationOnClickListener { dismiss() } + binding.topAppBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_refresh -> { + val context = requireContext() + val id = mediaId ?: return@setOnMenuItemClickListener false + context.deleteFxPrefs(id.hashCode()) + bind() + true + } + + else -> false + } + } + } + + companion object { + @SuppressLint("SetTextI18n") + fun FragmentAudioFxBinding.bind( + settings: SharedPreferences, onEqualizerClicked: () -> Unit + ) { + val speed = settings.getInt(PLAYBACK_SPEED, speedRange.indexOf(1f)) + val adapter = RulerAdapter(object : RulerAdapter.Listener { + override fun intervalText(value: Int) = "${speedRange.getOrNull(value) ?: 1f}x" + override fun onSelectItem(value: Int) { + speedValue.text = "${speedRange.getOrNull(value) ?: 1f}x" + settings.edit { putInt(PLAYBACK_SPEED, value) } + } + }) + + speedRecycler.adapter = adapter + adapter.submitList(List(speedRange.size) { index -> index to (index % 2 == 0) }, speed) + + pitchSwitch.isChecked = settings.getBoolean(CHANGE_PITCH, true) + pitch.setOnClickListener { + pitchSwitch.isChecked = !pitchSwitch.isChecked + } + pitchSwitch.setOnCheckedChangeListener { _, isChecked -> + settings.edit { putBoolean(CHANGE_PITCH, isChecked) } + } + bassBoostSlider.value = settings.getInt(BASS_BOOST, 0).toFloat() + bassBoostSlider.addOnChangeListener { _, value, _ -> + settings.edit { putInt(BASS_BOOST, value.toInt()) } + } + equalizer.setOnClickListener { onEqualizerClicked() } + } + + private fun openEqualizer(activity: ComponentActivity, sessionId: Int) { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName) + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, sessionId) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + val contract = ActivityResultContracts.StartActivityForResult() + activity.registerActivityResultLauncher(contract) {}.launch(intent) + } + + fun Fragment.onEqualizerClicked() { + val viewModel by activityViewModel() + val sessionId = viewModel.playerState.session.value + runCatching { openEqualizer(requireActivity(), sessionId) }.getOrElse { + viewModel.run { viewModelScope.launch { app.throwFlow.emit(it) } } + } + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/PlayerMoreFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/PlayerMoreFragment.kt new file mode 100644 index 0000000..2d8010e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/PlayerMoreFragment.kt @@ -0,0 +1,148 @@ +package dev.brahmkshatriya.echo.ui.player.more + +import android.annotation.SuppressLint +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment +import androidx.fragment.app.add +import androidx.fragment.app.commitNow +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.button.MaterialButton +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.FragmentPlayerMoreBinding +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors +import dev.brahmkshatriya.echo.ui.player.more.info.TrackInfoFragment +import dev.brahmkshatriya.echo.ui.player.more.lyrics.LyricsFragment +import dev.brahmkshatriya.echo.ui.player.more.upnext.QueueFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isLandscape +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class PlayerMoreFragment : Fragment() { + + private var binding by autoCleared() + private val uiViewModel by activityViewModel() + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentPlayerMoreBinding.inflate(inflater, container, false) + return binding.root + } + + private inline fun Fragment.addIfNull(): String { + val tag = F::class.java.simpleName + childFragmentManager.run { + if (findFragmentByTag(tag) == null) commitNow { + add(R.id.player_more_fragment_container, tag) + } + } + return tag + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + var topInset = 0 + val topMargin = requireContext().run { if (isLandscape()) 0 else 72.dpToPx(this) } + fun updateTranslateY() { + val offset = uiViewModel.moreSheetOffset.value + val inverted = 1 - offset + binding.root.translationY = -topInset * inverted + binding.playerMoreFragmentContainer.translationY = + (1 - offset) * 2 * uiViewModel.systemInsets.value.bottom + } + + observe(uiViewModel.systemInsets) { + topInset = it.top + topMargin + view.updatePaddingRelative(top = it.top) + updateTranslateY() + } + + observe(uiViewModel.moreSheetOffset) { + updateTranslateY() + binding.buttonToggleGroupBg.alpha = it + } + + observe(uiViewModel.moreSheetState) { + if (it == BottomSheetBehavior.STATE_COLLAPSED) binding.buttonToggleGroup.clearChecked() + else binding.buttonToggleGroup.check(uiViewModel.lastMoreTab) + } + + observe(uiViewModel.playerColors) { colorsNullable -> + val colors = colorsNullable ?: requireContext().defaultPlayerColors() + binding.buttonToggleGroupBg.children.forEach { + it as MaterialButton + it.backgroundTintList = ColorStateList.valueOf(colors.background) + } + + val textColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked) + ), + intArrayOf( + colors.background, + colors.onBackground + ) + ) + val foregroundColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked) + ), + intArrayOf( + colors.onBackground, + Color.TRANSPARENT + ) + ) + binding.buttonToggleGroup.children.forEach { + it as MaterialButton + it.setTextColor(textColorStateList) + it.backgroundTintList = foregroundColorStateList + } + } + + showFragment() + binding.buttonToggleGroup.addOnButtonCheckedListener { group, _, _ -> + uiViewModel.run { + val current = moreSheetState.value + if (current != BottomSheetBehavior.STATE_COLLAPSED && current != BottomSheetBehavior.STATE_EXPANDED) return@run + changeMoreState( + if (group.checkedButtonId != -1) BottomSheetBehavior.STATE_EXPANDED else BottomSheetBehavior.STATE_COLLAPSED + ) + } + showFragment() + } + + @SuppressLint("ClickableViewAccessibility") + val touchListener = View.OnTouchListener { v, _ -> + uiViewModel.lastMoreTab = v.id + false + } + binding.buttonToggleGroup.children.forEach { it.setOnTouchListener(touchListener) } + } + + private fun showFragment() { + val checkedId = binding.buttonToggleGroup.checkedButtonId + val toShow = when (checkedId) { + R.id.queue -> addIfNull() + R.id.lyrics -> addIfNull() + R.id.info -> addIfNull() + else -> null + } + childFragmentManager.commitNow { + childFragmentManager.fragments.forEach { fragment -> + if (fragment.tag != toShow) hide(fragment) + else show(fragment) + } + setReorderingAllowed(true) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoFragment.kt new file mode 100644 index 0000000..f109488 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoFragment.kt @@ -0,0 +1,22 @@ +package dev.brahmkshatriya.echo.ui.player.more.info + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.ui.media.MediaDetailsFragment +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import org.koin.androidx.viewmodel.ext.android.viewModel + +class TrackInfoFragment : Fragment( + R.layout.fragment_player_info +), MediaDetailsFragment.Parent { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view, axis = MaterialSharedAxis.Y) + } + + override val feedId = "player" + override val fromPlayer = true + override val viewModel by viewModel() +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoViewModel.kt new file mode 100644 index 0000000..74ed07c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/info/TrackInfoViewModel.kt @@ -0,0 +1,54 @@ +package dev.brahmkshatriya.echo.ui.player.more.info + +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.download.Downloader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.extensions.cache.Cached.loadMedia +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.media.MediaDetailsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch + +class TrackInfoViewModel( + val app: App, + extensionLoader: ExtensionLoader, + playerState: PlayerState, + downloader: Downloader +) : MediaDetailsViewModel( + downloader, app, true, + playerState.current.combine(extensionLoader.music) { current, music -> + current?.mediaItem?.extensionId?.let { id -> + music.find { it.id == id } + } + } +) { + val currentFlow = playerState.current + override fun getItem(): Triple? { + return currentFlow.value?.let { (_, item) -> + Triple(item.extensionId, item.track, item.isLoaded) + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + listOf(playerState.current, extensionFlow, refreshFlow).merge().collectLatest { + itemResultFlow.value = null + val extension = extensionFlow.value ?: return@collectLatest + val track = currentFlow.value?.mediaItem?.takeIf { it.isLoaded }?.track + ?: return@collectLatest + itemResultFlow.value = loadMedia( + app, extension, MediaState.Unloaded(extension.id, track) + ) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricAdapter.kt new file mode 100644 index 0000000..22c5978 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricAdapter.kt @@ -0,0 +1,89 @@ +package dev.brahmkshatriya.echo.ui.player.more.lyrics + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.databinding.ItemLoadingBinding +import dev.brahmkshatriya.echo.databinding.ItemLyricBinding +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class LyricAdapter( + val uiViewModel: UiViewModel, val listener: Listener, +) : ScrollAnimListAdapter(DiffCallback) { + fun interface Listener { + fun onLyricSelected(adapter: LyricAdapter, lyric: Lyrics.Item) + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Lyrics.Item, newItem: Lyrics.Item) = + oldItem.text == newItem.text + + override fun areContentsTheSame(oldItem: Lyrics.Item, newItem: Lyrics.Item) = + oldItem == newItem + } + + inner class ViewHolder(val binding: ItemLyricBinding) : ScrollAnimViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + val lyrics = getItem(bindingAdapterPosition) ?: return@setOnClickListener + listener.onLyricSelected(this@LyricAdapter, lyrics) + } + } + } + + private fun ViewHolder.updateColors() { + binding.root.run { + val colors = uiViewModel.playerColors.value ?: context.defaultPlayerColors() + val alphaStrippedColor = colors.onBackground or -0x1000000 + setTextColor(alphaStrippedColor) + } + } + + private fun getItemOrNull(position: Int) = runCatching { getItem(position) }.getOrNull() + + private var currentPos = -1 + private fun ViewHolder.updateCurrent() { + val currentTime = getItemOrNull(currentPos)?.startTime ?: 0 + val time = getItemOrNull(bindingAdapterPosition)?.startTime ?: 0 + binding.root.alpha = if (currentTime >= time) 1f else 0.5f + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemLyricBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val lyric = getItem(position) ?: return + holder.binding.root.text = lyric.text.trim().trim('\n').ifEmpty { "♪" } + holder.updateColors() + holder.updateCurrent() + holder.itemView.applyTranslationYAnimation(scrollY) + } + + override fun onViewAttachedToWindow(holder: ViewHolder) { + holder.updateColors() + } + + fun updateColors() { + onEachViewHolder { updateColors() } + } + + fun updateCurrent(currentPos: Int) { + this.currentPos = currentPos + onEachViewHolder { updateCurrent() } + } + class Loading( + parent: ViewGroup, + val binding: ItemLoadingBinding = ItemLoadingBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : FeedLoadingAdapter.ViewHolder(binding.root) +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsFragment.kt new file mode 100644 index 0000000..672cbc1 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsFragment.kt @@ -0,0 +1,249 @@ +package dev.brahmkshatriya.echo.ui.player.more.lyrics + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.CONSUMED +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.paging.LoadState.Error +import androidx.paging.LoadState.NotLoading +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.behavior.HideViewOnScrollBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.LyricsSearchClient +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.databinding.FragmentPlayerLyricsBinding +import dev.brahmkshatriya.echo.databinding.ItemLyricsItemBinding +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.UiViewModel +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionsListBottomSheet +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter.Companion.createListener +import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.activityViewModel + + +class LyricsFragment : Fragment() { + + private var binding by autoCleared() + private val viewModel by activityViewModel() + private val playerVM by activityViewModel() + private val uiViewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, + ): View { + binding = FragmentPlayerLyricsBinding.inflate(inflater, container, false) + return binding.root + } + + private var currentLyricsPos = -1 + private var currentLyrics: Lyrics.Lyric? = null + private val lyricAdapter by lazy { + LyricAdapter(uiViewModel) { adapter, lyric -> + if (adapter.itemCount <= 1) return@LyricAdapter + currentLyricsPos = -1 + playerVM.seekTo(lyric.startTime) + updateLyrics(lyric.startTime) + } + } + + private val lyricsErrorAdapter by lazy { + FeedLoadingAdapter(createListener { + viewModel.reloadCurrent() + }) { LyricAdapter.Loading(it) } + } + + private var shouldAutoScroll = true + val layoutManager by lazy { + binding.lyricsRecyclerView.layoutManager as LinearLayoutManager + } + + private fun updateLyrics(current: Long) { + val lyrics = currentLyrics as? Lyrics.Timed ?: return + val currentTime = lyrics.list.getOrNull(currentLyricsPos)?.endTime ?: -1 + if (currentTime < current || current <= 0) { + val currentIndex = lyrics.list.indexOfLast { lyric -> + lyric.startTime <= current + } + lyricAdapter.updateCurrent(currentIndex) + if (!shouldAutoScroll) return + binding.appBarLayout.setExpanded(false) + slideDown() + if (currentIndex < 0) return + val smoothScroller = CenterSmoothScroller(requireContext()) + smoothScroller.targetPosition = currentIndex + layoutManager.startSmoothScroll(smoothScroller) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view, false, axis = MaterialSharedAxis.Y) + FastScrollerHelper.applyTo(binding.lyricsRecyclerView) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, _ -> CONSUMED } + observe(uiViewModel.moreSheetState) { + binding.root.keepScreenOn = it == BottomSheetBehavior.STATE_EXPANDED + } + binding.searchBarText.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.menu_lyrics -> ExtensionsListBottomSheet.newInstance(ExtensionType.LYRICS) + .show(parentFragmentManager, null) + } + true + } + val menu = binding.searchBarText.menu + val extMenu = binding.searchBarText.findViewById(R.id.menu_lyrics) + extMenu.setOnLongClickListener { + val ext = viewModel.currentSelectionFlow.value ?: return@setOnLongClickListener false + val all = viewModel.extensionsFlow.value + val index = all.indexOf(ext) + val nextIndex = (index + 1) % all.size + if (nextIndex == index) return@setOnLongClickListener false + viewModel.selectExtension(nextIndex) + true + } + val lyricsItemAdapter = LyricsItemAdapter { lyrics -> + viewModel.onLyricsSelected(lyrics) + binding.searchView.hide() + } + GridAdapter.configureGridLayout( + binding.searchRecyclerView, + lyricsItemAdapter.withLoaders(this, viewModel), + ) + observe(viewModel.currentSelectionFlow) { current -> + binding.searchBarText.hint = current?.name + current?.metadata?.icon.loadAsCircle(extMenu, R.drawable.ic_extension_32dp) { + menu.findItem(R.id.menu_lyrics).icon = it + } + val isSearchable = current?.isClient() ?: false + binding.searchBarText.setNavigationIcon( + when (isSearchable) { + true -> R.drawable.ic_search_outline + false -> R.drawable.ic_queue_music + } + ) + binding.searchView.editText.isEnabled = isSearchable + binding.searchView.hint = if (isSearchable) + getString(R.string.search_x, current.name) + else current?.name + } + + binding.searchView.editText.setOnEditorActionListener { v, _, _ -> + viewModel.queryFlow.value = v.text.toString().trim() + true + } + + observe(viewModel.queryFlow) { + binding.searchView.editText.setText(it) + } + + observe(viewModel.pagingFlow) { + lyricsItemAdapter.submitData(it) + } + + var job: Job? = null + binding.lyricsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0) return + shouldAutoScroll = false + job?.cancel() + job = lifecycleScope.launch { + delay(3500) + shouldAutoScroll = true + } + } + }) + + observe(uiViewModel.playerColors) { + lyricAdapter.updateColors() + val colors = it ?: requireContext().defaultPlayerColors() + binding.noLyrics.setTextColor(colors.onBackground) + } + + binding.lyricsRecyclerView.adapter = ConcatAdapter(lyricsErrorAdapter, lyricAdapter) + binding.lyricsRecyclerView.itemAnimator = null + observe(viewModel.lyricsState) { state -> + binding.noLyrics.isVisible = state == LyricsViewModel.State.Empty + lyricsErrorAdapter.loadState = when (state) { + LyricsViewModel.State.Initial -> LoadState.Loading + LyricsViewModel.State.Empty -> NotLoading(true) + LyricsViewModel.State.Loading -> LoadState.Loading + is LyricsViewModel.State.Loaded -> state.result.fold({ + NotLoading(true) + }, { + Error(it) + }) + + } + val lyricsItem = (state as? LyricsViewModel.State.Loaded)?.result?.getOrNull() + binding.lyricsItem.bind(lyricsItem) + currentLyricsPos = -1 + currentLyrics = lyricsItem?.lyrics + val list = when (val lyrics = currentLyrics) { + is Lyrics.Simple -> listOf(Lyrics.Item(lyrics.text, 0, 0)) + is Lyrics.Timed -> lyrics.list + is Lyrics.WordByWord -> lyrics.list.flatten() + null -> emptyList() + } + lyricAdapter.submitList(list) + } + + observe(playerVM.progress) { updateLyrics(it.first) } + } + + fun ItemLyricsItemBinding.bind(lyrics: Lyrics?) = root.run { + if (lyrics == null) { + isVisible = false + return + } + isVisible = true + setTitle(lyrics.title) + setSubtitle(lyrics.subtitle) + setBackgroundResource(R.color.amoled_bg) + } + + class CenterSmoothScroller(context: Context) : LinearSmoothScroller(context) { + override fun calculateDtToFit( + viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int, snapPreference: Int, + ): Int { + val midPoint = boxEnd / 2 + val targetMidPoint = ((viewEnd - viewStart) / 2) + viewStart + return midPoint - targetMidPoint + } + + override fun getVerticalSnapPreference() = SNAP_TO_START + override fun calculateTimeForDeceleration(dx: Int) = 650 + } + + @SuppressLint("WrongConstant") + private fun slideDown() { + val params = binding.lyricsItem.root.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as HideViewOnScrollBehavior + behavior.setViewEdge(1) + behavior.slideOut(binding.lyricsItem.root) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsItemAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsItemAdapter.kt new file mode 100644 index 0000000..5b307ce --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsItemAdapter.kt @@ -0,0 +1,90 @@ +package dev.brahmkshatriya.echo.ui.player.more.lyrics + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.paging.LoadState +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.databinding.ItemLoadingSmallBinding +import dev.brahmkshatriya.echo.databinding.ItemShelfCategoryBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.feed.EmptyAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedAdapter.LoadingViewHolder +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter +import dev.brahmkshatriya.echo.ui.feed.FeedLoadingAdapter.Companion.createListener +import dev.brahmkshatriya.echo.ui.feed.TabsAdapter +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimPagingAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder +import kotlinx.coroutines.flow.combine + +class LyricsItemAdapter( + private val listener: Listener +) : ScrollAnimPagingAdapter(DiffCallback), GridAdapter { + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Lyrics, newItem: Lyrics) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Lyrics, newItem: Lyrics) = oldItem == newItem + } + + fun interface Listener { + fun onLyricsSelected(lyrics: Lyrics) + } + + class ViewHolder(val binding: ItemShelfCategoryBinding) : ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemShelfCategoryBinding.inflate(inflater, parent, false) + binding.icon.isVisible = false + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val lyrics = getItem(position) ?: return + holder.binding.run { + root.setOnClickListener { listener.onLyricsSelected(lyrics) } + title.text = lyrics.title + subtitle.text = lyrics.subtitle + subtitle.isVisible = !lyrics.subtitle.isNullOrBlank() + } + } + + data class Loading( + val parent: ViewGroup, + val binding: ItemLoadingSmallBinding = ItemLoadingSmallBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : FeedLoadingAdapter.ViewHolder(binding.root) + + fun withLoaders(fragment: Fragment, viewModel: LyricsViewModel): GridAdapter { + val tabs = TabsAdapter({ title }) { view, index, tab -> + viewModel.selectedTabIndexFlow.value = index + } + fragment.observe(viewModel.tabsFlow) { tabs.data = it } + fragment.observe(viewModel.selectedTabIndexFlow) { tabs.selected = it } + val loadStateListener = fragment.createListener { retry() } + val header = FeedLoadingAdapter(loadStateListener) { LoadingViewHolder(it) } + val footer = FeedLoadingAdapter(loadStateListener) { LoadingViewHolder(it) } + val empty = EmptyAdapter() + fragment.observe( + loadStateFlow.combine(viewModel.shouldShowEmpty) { a, b -> a to b } + ) { (loadStates, shouldShowEmpty) -> + val isEmpty = + shouldShowEmpty && itemCount == 0 && loadStates.append is LoadState.NotLoading + empty.loadState = if (isEmpty) LoadState.Loading else LoadState.NotLoading(false) + } + addLoadStateListener { loadStates -> + header.loadState = loadStates.refresh + footer.loadState = loadStates.append + } + return GridAdapter.Concat(tabs, header, empty, this, footer) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsViewModel.kt new file mode 100644 index 0000000..297cd7c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/lyrics/LyricsViewModel.kt @@ -0,0 +1,208 @@ +package dev.brahmkshatriya.echo.ui.player.more.lyrics + +import androidx.core.content.edit +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.paging.cachedIn +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.LyricsClient +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.Lyrics +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.cache.Cached +import dev.brahmkshatriya.echo.playback.MediaItemUtils.extensionId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.PlayerState +import dev.brahmkshatriya.echo.ui.common.PagedSource +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionListViewModel +import dev.brahmkshatriya.echo.utils.CacheUtils.getFromCache +import dev.brahmkshatriya.echo.utils.CacheUtils.saveToCache +import dev.brahmkshatriya.echo.utils.CoroutineUtils.combineTransformLatest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LyricsViewModel( + private val app: App, + extensionLoader: ExtensionLoader, + playerState: PlayerState, +) : ExtensionListViewModel>() { + + private val refreshFlow = MutableSharedFlow() + override val currentSelectionFlow = MutableStateFlow?>(null) + + val queryFlow = MutableStateFlow("") + val selectedTabIndexFlow = MutableStateFlow(-1) + val lyricsState = MutableStateFlow(State.Initial) + + private val mediaFlow = playerState.current.map { current -> + current?.mediaItem?.takeIf { it.isLoaded } + }.distinctUntilChanged().stateIn(viewModelScope, Eagerly, null) + + override val extensionsFlow = extensionLoader.lyrics.combine(mediaFlow) { lyrics, mediaItem -> + val trackExtension = mediaItem?.extensionId?.let { id -> + extensionLoader.music.getExtension(id)?.takeIf { it.isClient() } + } + listOfNotNull(trackExtension) + lyrics + }.onEach { extensions -> + currentSelectionFlow.value = null + lyricsState.value = State.Initial + queryFlow.value = "" + val media = mediaFlow.value?.track?.id + currentSelectionFlow.value = media?.let { + val id = app.context.getFromCache(media, "lyrics_ext") + ?: app.settings.getString(LAST_LYRICS_KEY, null) + extensions.find { it.id == id } ?: extensions.firstOrNull() + } + refreshFlow.emit(Unit) + }.stateIn(viewModelScope, Eagerly, emptyList()) + + override fun onExtensionSelected(extension: Extension<*>) { + app.settings.edit { putString(LAST_LYRICS_KEY, extension.id) } + val media = mediaFlow.value?.track?.id ?: return + app.context.saveToCache(media, extension.id, "lyrics_ext") + } + + fun reloadCurrent() = viewModelScope.launch { refreshFlow.emit(Unit) } + + private val cachedFeed = combineTransformLatest( + currentSelectionFlow, mediaFlow, queryFlow, refreshFlow + ) { + emit(null) + val extension = it[0] as Extension<*>? ?: return@combineTransformLatest + val item = it[1] as MediaItem? ?: return@combineTransformLatest + val query = it[2] as String + val result = Cached.getLyricsFeed(app, extension.id, item.extensionId, item.track, query) + emit(result) + }.stateIn(viewModelScope, Eagerly, null) + + private val loadedFeed = combineTransformLatest( + currentSelectionFlow, mediaFlow, queryFlow, refreshFlow + ) { + emit(null) + val extension = it[0] as Extension<*>? ?: return@combineTransformLatest + val item = it[1] as MediaItem? ?: return@combineTransformLatest + val query = it[2] as String + val result = Cached.loadLyricsFeed(app, extension, item.extensionId, item.track, query) + emit(result) + }.stateIn(viewModelScope, Eagerly, null) + + private val feedFlow = loadedFeed.combine(cachedFeed) { loaded, cache -> + cache to loaded + }.stateIn(viewModelScope, Eagerly, null to null) + + val tabsFlow = feedFlow.map { (cached, loaded) -> + val state = (loaded?.getOrNull() ?: cached?.getOrNull()) ?: return@map listOf() + state.tabs + } + + private suspend fun getData( + feed: Result>?, index: Int, + ) = withContext(Dispatchers.IO) { + feed?.mapCatching { + val paged = it.getPagedData(it.tabs.run { getOrNull(index) ?: firstOrNull() }).pagedData + paged + } + } + + private val cachedDataFlow = + cachedFeed.combineTransformLatest(selectedTabIndexFlow) { feed, tab -> + emit(null) + if (feed == null) return@combineTransformLatest + emit(getData(feed, tab)) + }.stateIn(viewModelScope, Lazily, null) + + private val loadedDataFlow = + loadedFeed.combineTransformLatest(selectedTabIndexFlow) { feed, tab -> + emit(null) + if (feed == null) return@combineTransformLatest + emit(getData(feed, tab)) + }.stateIn(viewModelScope, Lazily, null) + + private val dataFlow = loadedDataFlow.combine(cachedDataFlow) { loaded, cache -> + cache to loaded + }.stateIn(viewModelScope, Lazily, null to null) + + val shouldShowEmpty = dataFlow.map { (cached, loaded) -> + val data = loaded?.getOrNull() ?: cached?.getOrNull() + data != null + }.stateIn(viewModelScope, Lazily, false) + + @OptIn(ExperimentalCoroutinesApi::class) + val pagingFlow = dataFlow.transformLatest { (cached, loaded) -> + emitAll(PagedSource(loaded, cached).flow) + }.flowOn(Dispatchers.IO).cachedIn(viewModelScope) + + sealed interface State { + data object Initial : State + data object Loading : State + data object Empty : State + data class Loaded(val result: Result) : State + } + + fun onLyricsSelected(lyricsItem: Lyrics?) = viewModelScope.launch(Dispatchers.IO) { + lyricsState.value = State.Loading + if (lyricsItem == null) lyricsState.value = State.Empty else { + val extension = currentSelectionFlow.value ?: return@launch + lyricsState.value = + State.Loaded(Cached.loadLyrics(app, extension, lyricsItem).map { it.fillGaps() }) + } + } + + private fun Lyrics.fillGaps(): Lyrics { + val lyrics = this.lyrics as? Lyrics.Timed + return if (lyrics != null && lyrics.fillTimeGaps) { + val new = mutableListOf() + var last = 0L + lyrics.list.forEach { + if (it.startTime > last) { + new.add(Lyrics.Item("", last, it.startTime)) + } + new.add(it) + last = it.endTime + } + this.copy(lyrics = Lyrics.Timed(new)) + } else this + } + + init { + reloadCurrent() + viewModelScope.launch(Dispatchers.IO) { + dataFlow.collectLatest { (cached, loaded) -> + if (lyricsState.value != State.Initial) return@collectLatest + runCatching { + val cachedLyrics = cached?.getOrNull()?.loadAll()?.firstOrNull() + val loaded = loaded?.getOrNull() + if (loaded != null) { + lyricsState.value = State.Loading + onLyricsSelected(loaded.loadPage(null).data.firstOrNull()) + } else if (cachedLyrics != null) { + onLyricsSelected(cachedLyrics) + } + } + } + } + } + + companion object { + const val LAST_LYRICS_KEY = "last_lyrics_client" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueAdapter.kt new file mode 100644 index 0000000..f9ae02c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueAdapter.kt @@ -0,0 +1,140 @@ +package dev.brahmkshatriya.echo.ui.player.more.upnext + +import android.annotation.SuppressLint +import android.graphics.drawable.Animatable +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.media3.common.MediaItem +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemPlaylistTrackBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.subtitle +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation +import dev.brahmkshatriya.echo.utils.ui.UiUtils.marquee +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class QueueAdapter( + private val listener: Listener, + private val inactive: Boolean = false +) : ListAdapter, QueueAdapter.ViewHolder>(DiffCallback) { + + object DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem.second.mediaId == newItem.second.mediaId + + override fun areContentsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem == newItem + + } + + open class Listener { + open fun onItemClicked(position: Int) {} + open fun onItemClosedClicked(position: Int) {} + open fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) {} + } + + @SuppressLint("ClickableViewAccessibility") + inner class ViewHolder( + val binding: ItemPlaylistTrackBinding + ) : ScrollAnimViewHolder(binding.root) { + + init { + binding.playlistItemClose.setOnClickListener { + val pos = bindingAdapterPosition + if (pos == RecyclerView.NO_POSITION) return@setOnClickListener + listener.onItemClosedClicked(pos) + } + + binding.root.setOnClickListener { + val pos = bindingAdapterPosition + if (pos == RecyclerView.NO_POSITION) return@setOnClickListener + listener.onItemClicked(pos) + } + + binding.playlistItemDrag.setOnTouchListener { _, event -> + val pos = bindingAdapterPosition + if (pos == RecyclerView.NO_POSITION) return@setOnTouchListener false + if (event.actionMasked != MotionEvent.ACTION_DOWN) return@setOnTouchListener false + listener.onDragHandleTouched(this) + true + } + } + + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return ViewHolder(ItemPlaylistTrackBinding.inflate(inflater, parent, false)) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.onBind(position) + holder.itemView.applyTranslationYAnimation(scrollAmount) + } + + private fun ViewHolder.onBind(position: Int) { + val (current, item) = getItem(position) + val isCurrent = current != null + val isPlaying = current == true + val track = item.track + binding.bind(track) + binding.isPlaying(isPlaying) + binding.playlistItemClose.isVisible = !inactive + binding.playlistItemDrag.isVisible = !inactive + binding.playlistCurrentItem.isVisible = isCurrent + binding.playlistProgressBar.isVisible = isCurrent && !item.isLoaded + binding.playlistItem.alpha = if (inactive) 0.5f else 1f + } + + private var scrollAmount: Int = 0 + private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollAmount = dy + } + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + companion object { + fun ItemPlaylistTrackBinding.bind(track: Track) { + playlistItemTitle.run { + text = track.title + marquee() + } + + track.cover.loadInto(playlistItemImageView, R.drawable.art_music) + val subtitle = track.subtitle(root.context) + playlistItemAuthor.run { + isVisible = !subtitle.isNullOrEmpty() + text = subtitle + marquee() + } + } + + fun ItemPlaylistTrackBinding.isPlaying(isPlaying: Boolean) { + playlistItemNowPlaying.isVisible = isPlaying + (playlistItemNowPlaying.drawable as Animatable).start() + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueFragment.kt new file mode 100644 index 0000000..9345df4 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/more/upnext/QueueFragment.kt @@ -0,0 +1,109 @@ +package dev.brahmkshatriya.echo.ui.player.more.upnext + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.databinding.FragmentPlayerQueueBinding +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoClearedNullable +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class QueueFragment : Fragment() { + + private var binding by autoClearedNullable() + private val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentPlayerQueueBinding.inflate(inflater, container, false) + return binding!!.root + } + + private val queueAdapter by lazy { + QueueAdapter(object : QueueAdapter.Listener() { + override fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + + override fun onItemClicked(position: Int) { + viewModel.play(position) + } + + override fun onItemClosedClicked(position: Int) { + viewModel.removeQueueItem(position) + } + }) + } + + private val touchHelper by lazy { + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + viewModel.moveQueueItems(fromPos, toPos) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val pos = viewHolder.bindingAdapterPosition + viewModel.removeQueueItem(pos) + } + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return makeMovementFlags( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START + ) + } + }) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view, false, axis = MaterialSharedAxis.Y) + val recyclerView = binding!!.root + recyclerView.adapter = queueAdapter + touchHelper.attachToRecyclerView(recyclerView) + val manager = recyclerView.layoutManager as LinearLayoutManager + val screenHeight = view.resources.displayMetrics.heightPixels / 3 + + fun submit() { + val current = viewModel.playerState.current.value + val currentIndex = current?.index + val it = viewModel.queue.mapIndexed { index, mediaItem -> + if (currentIndex == index) current.isPlaying to current.mediaItem + else null to mediaItem + } + queueAdapter.submitList(it) { + currentIndex ?: return@submitList + binding?.root?.scrollToPosition(currentIndex) + } + } + + observe(viewModel.playerState.current) { submit() } + observe(viewModel.queueFlow) { submit() } + + val index = viewModel.playerState.current.value?.index ?: return + manager.scrollToPositionWithOffset(index + 1, screenHeight) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/FormatUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/FormatUtils.kt new file mode 100644 index 0000000..3626536 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/FormatUtils.kt @@ -0,0 +1,78 @@ +package dev.brahmkshatriya.echo.ui.player.quality + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Streamable + +object FormatUtils { + @OptIn(UnstableApi::class) + private fun Format.getBitrate() = + (bitrate / 1000).takeIf { it > 0 }?.let { " • $it kbps" } ?: "" + + private fun Format.getFrameRate() = + frameRate.toInt().takeIf { it > 0 }?.let { " • $it fps" } ?: "" + + + private fun Format.getMimeType() = when (val mime = sampleMimeType?.replace("audio/", "")) { + "mp4a-latm" -> "AAC" + else -> mime?.uppercase() + } + + private fun Format.getHertz() = + sampleRate.takeIf { it > 0 }?.let { " • $it Hz" } ?: "" + + private fun Format.getChannelCount() = + channelCount.takeIf { it > 0 }?.let { " • ${it}ch" } ?: "" + + @OptIn(UnstableApi::class) + fun Format.toAudioDetails() = + "${getMimeType()}${getHertz()}${getChannelCount()}${getBitrate()}" + + fun Format.toVideoDetails() = "${height}p${getFrameRate()}${getBitrate()}" + fun Format.toSubtitleDetails() = label ?: language ?: "Unknown" + + private fun List.getSelectedFormat(): Format? { + return firstNotNullOfOrNull { trackGroup -> + val index = (0 until trackGroup.length).firstNotNullOfOrNull { i -> + if (trackGroup.isTrackSelected(i)) i else null + } ?: return null + trackGroup.getTrackFormat(index) + } + } + + fun List.getSelected(): Pair>, Int?> { + var selected: Pair? = null + val trackGroups = map { trackGroup -> + (0 until trackGroup.length).map { i -> + val pair = Pair(trackGroup, i) + val isSelected = trackGroup.isTrackSelected(i) + if (isSelected) selected = pair + pair + } + }.flatten() + val select = trackGroups.indexOf(selected).takeIf { it != -1 } + return trackGroups to select + } + + fun Tracks.getDetails( + context: Context, server: Streamable.Media.Server?, index: Int?, + ): List { + val audios = groups.filter { it.type == C.TRACK_TYPE_AUDIO } + val videos = groups.filter { it.type == C.TRACK_TYPE_VIDEO } + val subtitles = groups.filter { it.type == C.TRACK_TYPE_TEXT } + val sourceTitle = server?.run { + if (merged) sources.mapNotNull { it.title } + else listOfNotNull(sources.getOrNull(index ?: -1)?.title) + }.orEmpty() + return sourceTitle + listOfNotNull( + audios.getSelectedFormat()?.toAudioDetails(), + videos.getSelectedFormat()?.toVideoDetails(), + subtitles.getSelectedFormat()?.toSubtitleDetails() + ).ifEmpty { listOf(context.getString(R.string.unknown_quality)) } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/QualitySelectionBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/QualitySelectionBottomSheet.kt new file mode 100644 index 0000000..0fc89cf --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/quality/QualitySelectionBottomSheet.kt @@ -0,0 +1,187 @@ +package dev.brahmkshatriya.echo.ui.player.quality + +import android.annotation.SuppressLint +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.OptIn +import androidx.core.view.isVisible +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Streamable +import dev.brahmkshatriya.echo.databinding.DialogPlayerQualitySelectionBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.backgroundIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.serverWithDownloads +import dev.brahmkshatriya.echo.playback.MediaItemUtils.subtitleIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.getDetails +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.getSelected +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.toAudioDetails +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.toSubtitleDetails +import dev.brahmkshatriya.echo.ui.player.quality.FormatUtils.toVideoDetails +import dev.brahmkshatriya.echo.ui.settings.SettingsPlayerFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class QualitySelectionBottomSheet : BottomSheetDialogFragment() { + var binding by autoCleared() + private val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, + ): View { + binding = DialogPlayerQualitySelectionBinding.inflate(inflater, container, false) + return binding.root + } + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.topAppBar.setNavigationOnClickListener { dismiss() } + binding.topAppBar.setOnMenuItemClickListener { + dismiss() + requireActivity().openFragment() + true + } + + observe(viewModel.buffering) { + binding.progressIndicator.isVisible = it + } + observe(viewModel.playerState.current) { current -> + val item = current?.mediaItem ?: return@observe + val track = item.track + binding.run { + applyChips( + item.serverWithDownloads(requireContext()), + streamableServer, streamableServerGroup, item.serverIndex + ) { + it ?: return@applyChips + viewModel.changeServer(it) + } + streamableServer.isVisible = true + streamableServerGroup.isVisible = true + + applyChips( + listOf(null, *track.backgrounds.toTypedArray()), streamableBackgrounds, + streamableBackgroundGroup, item.backgroundIndex + 1 + ) { + viewModel.changeBackground(it) + } + + applyChips( + listOf(null, *track.subtitles.toTypedArray()), streamableSubtitles, + streamableSubtitleGroup, item.subtitleIndex + 1 + ) { + viewModel.changeSubtitle(it) + } + } + } + + observe(viewModel.serverAndTracks) { (tracks, server, index) -> + val list = if (server != null && !server.merged) server.sources else listOf() + binding.run { + applyChips( + list, streamableSource, streamableSourceGroup, index, + { viewModel.changeCurrentSource(list.indexOf(it)) }) { + it.title ?: getString(R.string.quality_x, it.quality) + } + } + val details = tracks?.getDetails(requireContext(), server, index)?.joinToString("\n") + binding.streamableInfo.text = details + binding.streamableInfo.isVisible = !details.isNullOrBlank() + + val audios = tracks?.groups?.filter { it.type == C.TRACK_TYPE_AUDIO } + val videos = tracks?.groups?.filter { it.type == C.TRACK_TYPE_VIDEO } + val subtitles = tracks?.groups?.filter { it.type == C.TRACK_TYPE_TEXT } + + val onClick: Chip.(Pair?) -> Unit = { + val trackGroup = it!!.first.mediaTrackGroup + viewModel.changeTrackSelection(trackGroup, it.second) + } + + binding.run { + applyChips(audios, trackAudios, trackAudiosGroup, onClick) { + it.toAudioDetails() + } + applyChips(videos, trackVideos, trackVideosGroup, onClick) { + it.toVideoDetails() + } + applyChips(subtitles, trackSubtitles, trackSubtitlesGroup, onClick) { + it.toSubtitleDetails() + } + } + } + } + + @OptIn(UnstableApi::class) + private fun applyChips( + groups: List?, + textView: TextView, + chipGroup: ChipGroup, + onClick: Chip.(Pair?) -> Unit, + text: (Format) -> String, + ) { + val (trackGroups, select) = groups.orEmpty().getSelected() + applyChips(trackGroups, textView, chipGroup, select, onClick) { + val format = it.first.getTrackFormat(it.second) + text(format) + } + } + + private fun applyChips( + streamables: List, + textView: TextView, + chipGroup: ChipGroup, + selected: Int?, + onClick: Chip.(Streamable?) -> Unit, + ) { + val context = chipGroup.context + applyChips(streamables, textView, chipGroup, selected, onClick) { + it?.let { + it.title ?: when (it.type) { + Streamable.MediaType.Subtitle -> context.getString(R.string.unknown) + else -> context.getString(R.string.quality_x, it.quality) + } + } ?: context.getString(R.string.off) + } + } + + private fun applyChips( + list: List, + textView: TextView, + chipGroup: ChipGroup, + selected: Int?, + onClick: Chip.(T) -> Unit, + text: (T) -> String, + ) { + val visible = list.size > 1 + textView.isVisible = visible + chipGroup.apply { + isVisible = visible + removeAllViews() + list.forEachIndexed { index, t -> + val chip = Chip(context) + chip.text = text(t) + chip.ellipsize = TextUtils.TruncateAt.MIDDLE + chip.isCheckable = true + addView(chip) + if (index == selected) check(chip.id) + chip.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) onClick(chip, t) + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/sleep/SleepTimerBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/sleep/SleepTimerBottomSheet.kt new file mode 100644 index 0000000..43c9c77 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/player/sleep/SleepTimerBottomSheet.kt @@ -0,0 +1,145 @@ +package dev.brahmkshatriya.echo.ui.player.sleep + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.edit +import androidx.media3.common.C +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.DialogPlayerSleepTimerBinding +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.RulerAdapter +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import java.util.Calendar + +class SleepTimerBottomSheet : BottomSheetDialogFragment() { + var binding by autoCleared() + private val viewModel by activityViewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = DialogPlayerSleepTimerBinding.inflate(inflater, container, false) + return binding.root + } + + private val adapter by lazy { + RulerAdapter(object : RulerAdapter.Listener { + override fun intervalText(value: Int) = value.toString() + override fun onSelectItem(value: Int) { + rulerTime = value + binding.sleepTimerValue.text = requireContext().createString(value * 60L * 1000) + } + }) + } + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.sleepTimerRecycler.adapter = adapter + val last = viewModel.settings.getInt("sleep_timer", 5) + adapter.submitList(timeRangeWithIntervals, last) + + binding.okay.setOnClickListener { saveAndDismiss(rulerTime) } + binding.endOfTrack.setOnClickListener { setTimerAndDismiss(Long.MAX_VALUE) } + binding.min15.setOnClickListener { saveAndDismiss(15) } + binding.min30.setOnClickListener { saveAndDismiss(30) } + binding.min45.setOnClickListener { saveAndDismiss(45) } + binding.hr1.setOnClickListener { saveAndDismiss(60) } + binding.hr2.setOnClickListener { saveAndDismiss(120) } + + binding.topAppBar.setNavigationOnClickListener { setTimerAndDismiss(0) } + binding.topAppBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_schedule -> { + val is24Hour = DateFormat.is24HourFormat(context) + val picker = MaterialTimePicker.Builder() + .setTimeFormat(if (is24Hour) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H) + .setTitleText(getString(R.string.sleep_on_specific_time)) + .build() + picker.addOnCancelListener { picker.dismiss() } + picker.addOnPositiveButtonClickListener { + val now = Calendar.getInstance() + val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + val pickerMin = picker.hour * 60 + picker.minute + val time = if (pickerMin > nowMin) pickerMin - nowMin + else 1440 - nowMin + pickerMin + setTimerAndDismiss(time * 60L * 1000) + } + picker.show(childFragmentManager, null) + true + } + + else -> false + } + } + } + + private var timer = 0L + private fun setTimerAndDismiss(ms: Long) { + timer = ms + dismiss() + } + + private fun saveAndDismiss(mins: Int) { + viewModel.settings.edit { putInt("sleep_timer", mins) } + val ms = mins * 60L * 1000 + setTimerAndDismiss(ms) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + viewModel.setSleepTimer(timer) + val message = when (timer) { + 0L -> getString(R.string.sleep_timer_cancelled) + Long.MAX_VALUE -> { + val until = viewModel.browser.value?.run { + (duration.takeIf { it != C.TIME_UNSET } ?: 0) - currentPosition + } ?: 0 + getString(R.string.sleep_timer_set_for, requireContext().createString(until)) + } + + else -> getString(R.string.sleep_timer_set_for, requireContext().createString(timer)) + } + createSnack(message) + } + + private var rulerTime = 5 + + companion object { + private const val MAX = 360 + private const val MIN = 5 + private const val STEPS = 5 + private const val INTERVAL = 15 + + val timeRangeWithIntervals = 0.rangeTo((MAX - MIN) / STEPS).map { + val value = MIN + it * STEPS + value to (value == MIN || value % INTERVAL == 0) + } + + private fun Context.createString(ms: Long): String { + val minutes = ms / 1000 / 60 + val hrs = minutes / 60 + val min = minutes % 60 + val str = StringBuilder() + if (hrs > 0) str.append( + runCatching { resources.getQuantityString(R.plurals.number_hour, hrs.toInt(), hrs) } + .getOrNull() ?: getString(R.string.n_hours, hrs) + ).append(if (min > 0) " " else "") + if (min > 0) str.append( + runCatching { resources.getQuantityString(R.plurals.number_min, min.toInt(), min) } + .getOrNull() ?: getString(R.string.n_minutes, min) + ) + return str.toString() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/SelectableMediaAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/SelectableMediaAdapter.kt new file mode 100644 index 0000000..2a2f351 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/SelectableMediaAdapter.kt @@ -0,0 +1,124 @@ +package dev.brahmkshatriya.echo.ui.playlist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.databinding.ItemMediaSelectableBinding +import dev.brahmkshatriya.echo.databinding.ItemSelectableHeaderBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.feed.viewholders.shelf.ShelfViewHolder.Companion.bind +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class SelectableMediaAdapter( + val listener: Listener +) : ScrollAnimListAdapter, SelectableMediaAdapter.ViewHolder>( + object : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: Pair, newItem: Pair + ) = oldItem.first.id == newItem.first.id + + override fun areContentsTheSame( + oldItem: Pair, newItem: Pair + ) = oldItem == newItem + } +), GridAdapter { + + fun interface Listener { + fun onItemSelected(selected: Boolean, item: EchoMediaItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent, listener) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + holder.bind(getItem(position)) + } + + class ViewHolder( + parent: ViewGroup, + val listener: Listener, + val binding: ItemMediaSelectableBinding = ItemMediaSelectableBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + + private var item: EchoMediaItem? = null + + init { + binding.media.root.setOnClickListener { + val item = item ?: return@setOnClickListener + listener.onItemSelected(!binding.selected.isVisible, item) + } + binding.media.coverContainer.cover.clipToOutline = true + binding.media.coverContainer.isPlaying.isVisible = false + } + + fun bind(item: Pair) { + val mediaItem = item.first + this.item = mediaItem + binding.media.bind(mediaItem) + binding.selected.isVisible = item.second + } + } + + private var header: Header? = null + fun withHeader(selectAll: (Boolean) -> Unit): GridAdapter.Concat { + val header = Header(selectAll) + this.header = header + return GridAdapter.Concat(header, this) + } + + override fun onCurrentListChanged( + previousList: MutableList>, + currentList: MutableList> + ) { + header?.submitList( + currentList.count { it.second }, + currentList.all { it.second } + ) + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = 1 + + class Header( + private val onSelectAll: (Boolean) -> Unit + ) : ScrollAnimRecyclerAdapter(), GridAdapter { + class ViewHolder(val binding: ItemSelectableHeaderBinding) : + ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemSelectableHeaderBinding.inflate(inflater, parent, false) + binding.root.setOnClickListener { + onSelectAll(!binding.selectAll.isChecked) + } + return ViewHolder(binding) + } + + override fun getItemCount() = 1 + private var count = 0 + private var selected = false + + fun submitList(count: Int, selected: Boolean) { + this.count = count + this.selected = selected + notifyItemChanged(0) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + holder.binding.selected.run { + text = context.getString(R.string.selected_n, count) + } + holder.binding.selectAll.isChecked = selected + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistBottomSheet.kt new file mode 100644 index 0000000..fda9b9c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistBottomSheet.kt @@ -0,0 +1,81 @@ +package dev.brahmkshatriya.echo.ui.playlist.create + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.DialogPlaylistCreateBinding +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import org.koin.androidx.viewmodel.ext.android.viewModel + +class CreatePlaylistBottomSheet : BottomSheetDialogFragment() { + + var binding by autoCleared() + val viewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = DialogPlaylistCreateBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.playlistName.setOnEditorActionListener { _, _, _ -> + binding.playlistDesc.requestFocus() + true + } + + binding.playlistDesc.setOnEditorActionListener { _, _, _ -> + createPlaylist() + true + } + + binding.playlistCreateButton.setOnClickListener { createPlaylist() } + binding.topAppBar.setNavigationOnClickListener { dismiss() } + + observe(viewModel.createPlaylistStateFlow) { + when (it) { + CreateState.CreatePlaylist -> { + binding.nestedScrollView.isVisible = true + binding.saving.root.isVisible = false + } + + CreateState.Creating -> { + binding.nestedScrollView.isVisible = false + binding.saving.root.isVisible = true + binding.saving.textView.text = + getString(R.string.creating_x, binding.playlistName.text) + } + + is CreateState.PlaylistCreated -> { + if (it.playlist != null) parentFragmentManager.setFragmentResult( + "createPlaylist", + Bundle().apply { + putString("extensionId", it.extensionId) + putSerialized("playlist", it.playlist) + } + ) + viewModel.createPlaylistStateFlow.value = CreateState.CreatePlaylist + dismiss() + } + } + } + } + + private fun createPlaylist() { + val title = binding.playlistName.text.toString() + if (title.isEmpty()) { + binding.playlistName.error = getString(R.string.playlist_name_empty) + binding.playlistName.requestFocus() + return + } + val desc = binding.playlistDesc.text.toString().takeIf { it.isNotBlank() } + viewModel.createPlaylist(title, desc) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistViewModel.kt new file mode 100644 index 0000000..dd0dc7e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreatePlaylistViewModel.kt @@ -0,0 +1,30 @@ +package dev.brahmkshatriya.echo.ui.playlist.create + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class CreatePlaylistViewModel( + val app: App, + val extensionLoader: ExtensionLoader, +) : ViewModel() { + val createPlaylistStateFlow = + MutableStateFlow(CreateState.CreatePlaylist) + fun createPlaylist(title: String, desc: String?) { + val extension = extensionLoader.current.value ?: return + createPlaylistStateFlow.value = CreateState.Creating + viewModelScope.launch(Dispatchers.IO) { + val playlist = extension.getIf(app.throwFlow) { + createPlaylist(title, desc) + } + createPlaylistStateFlow.value = CreateState.PlaylistCreated(extension.id, playlist) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreateState.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreateState.kt new file mode 100644 index 0000000..1d59e0b --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/create/CreateState.kt @@ -0,0 +1,9 @@ +package dev.brahmkshatriya.echo.ui.playlist.create + +import dev.brahmkshatriya.echo.common.models.Playlist + +sealed class CreateState { + data object CreatePlaylist : CreateState() + data object Creating : CreateState() + data class PlaylistCreated(val extensionId: String, val playlist: Playlist?) : CreateState() +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistBottomSheet.kt new file mode 100644 index 0000000..827a860 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistBottomSheet.kt @@ -0,0 +1,87 @@ +package dev.brahmkshatriya.echo.ui.playlist.delete + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentActivity +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.databinding.ItemLoadingBinding +import dev.brahmkshatriya.echo.ui.common.SnackBarHandler.Companion.createSnack +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + + +class DeletePlaylistBottomSheet : BottomSheetDialogFragment(R.layout.item_loading) { + + companion object { + fun show( + activity: FragmentActivity, extensionId: String, item: Playlist, loaded: Boolean = false + ): AlertDialog = with(activity) { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.confirmation)) + .setMessage(getString(R.string.delete_playlist_confirmation, item.title)) + .setPositiveButton(getString(R.string.confirm)) { _, _ -> + newInstance(extensionId, item, loaded).show(supportFragmentManager, null) + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + }.show() + } + + private fun newInstance( + extensionId: String, item: Playlist, loaded: Boolean + ): DeletePlaylistBottomSheet { + return DeletePlaylistBottomSheet().apply { + arguments = Bundle().apply { + putString("extensionId", extensionId) + putSerialized("item", item) + putBoolean("loaded", loaded) + } + } + } + } + + val args by lazy { requireArguments() } + val extensionId by lazy { args.getString("extensionId")!! } + val item by lazy { args.getSerialized("item")!!.getOrThrow() } + val loaded by lazy { args.getBoolean("loaded", false) } + + val vm by viewModel { + parametersOf(extensionId, item, loaded) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = ItemLoadingBinding.bind(view) + observe(vm.deleteStateFlow) { state -> + val result = vm.playlistFlow.value + val playlist = result?.getOrNull() + val string = when (state) { + is DeleteState.Deleted -> { + if (state.result.isSuccess) { + createSnack(getString(R.string.deleted_x, playlist?.title)) + parentFragmentManager.setFragmentResult( + "deleted", bundleOf("id" to playlist?.id) + ) + parentFragmentManager.setFragmentResult("reloadLibrary", Bundle.EMPTY) + } + dismiss() + return@observe + } + + DeleteState.Deleting -> + getString(R.string.deleting_x, playlist?.title) + + DeleteState.Initial -> + getString(R.string.loading_x, playlist?.title) + } + binding.textView.text = string + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistViewModel.kt new file mode 100644 index 0000000..fd5ef13 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeletePlaylistViewModel.kt @@ -0,0 +1,59 @@ +package dev.brahmkshatriya.echo.ui.playlist.delete + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class DeletePlaylistViewModel( + extensionLoader: ExtensionLoader, + private val app: App, + private val extensionId: String, + private val item: Playlist, + private val loaded: Boolean, +) : ViewModel() { + + val extensionFlow = extensionLoader.music.map { list -> + list.find { it.id == extensionId } + }.stateIn(viewModelScope, Eagerly, null) + + val playlistFlow = extensionFlow.transformLatest { + emit(null) + if (it == null) return@transformLatest + if (loaded) emit(Result.success(item)) + else emit(it.getAs { loadPlaylist(item) }) + }.stateIn(viewModelScope, Eagerly, null) + + private val deleteFlow = MutableSharedFlow() + fun delete() = viewModelScope.launch { deleteFlow.emit(Unit) } + + val deleteStateFlow = deleteFlow.transformLatest { + emit(DeleteState.Deleting) + val result = runCatching { + val extension = extensionFlow.value!! + val playlist = playlistFlow.value!!.getOrThrow() + extension.getAs { deletePlaylist(playlist) }.getOrThrow() + } + result.getOrElse { app.throwFlow.emit(it) } + emit(DeleteState.Deleted(result)) + }.stateIn(viewModelScope, Eagerly, DeleteState.Initial) + + init { + viewModelScope.launch { + playlistFlow.first { it != null } + delete() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeleteState.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeleteState.kt new file mode 100644 index 0000000..0d709c3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/delete/DeleteState.kt @@ -0,0 +1,7 @@ +package dev.brahmkshatriya.echo.ui.playlist.delete + +sealed class DeleteState { + data object Initial : DeleteState() + data object Deleting : DeleteState() + data class Deleted(val result: Result) : DeleteState() +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistBottomSheet.kt new file mode 100644 index 0000000..edd84dd --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistBottomSheet.kt @@ -0,0 +1,73 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.databinding.ItemLoadingBinding +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class EditPlaylistBottomSheet : BottomSheetDialogFragment(R.layout.item_loading) { + companion object { + fun newInstance( + extensionId: String, playlist: Playlist, tabId: String?, index: Int + ) = EditPlaylistBottomSheet().apply { + arguments = Bundle().apply { + putString("extensionId", extensionId) + putSerialized("playlist", playlist) + putString("tabId", tabId) + putInt("removeIndex", index) + } + } + + fun EditPlaylistViewModel.SaveState.toText( + playlist: Playlist, context: Context + ) = when (this) { + is EditPlaylistViewModel.SaveState.Performing -> when (action) { + is EditPlaylistViewModel.Action.Add -> + context.getString(R.string.adding_x, tracks.joinToString(", ") { it.title }) + + is EditPlaylistViewModel.Action.Move -> + context.getString(R.string.moving_x, tracks.first().title) + + is EditPlaylistViewModel.Action.Remove -> + context.getString(R.string.removing_x, tracks.joinToString(", ") { it.title }) + } + + EditPlaylistViewModel.SaveState.Saving -> + context.getString(R.string.saving_x, playlist.title) + + EditPlaylistViewModel.SaveState.Initial -> context.getString(R.string.loading) + is EditPlaylistViewModel.SaveState.Saved -> context.getString(R.string.loading) + } + } + + val args by lazy { requireArguments() } + val extensionId by lazy { args.getString("extensionId")!! } + val playlist by lazy { args.getSerialized("playlist")!!.getOrThrow() } + val tabId by lazy { args.getString("tabId") } + val removeIndex by lazy { args.getInt("removeIndex", -1).takeIf { it != -1 }!! } + + val vm by viewModel { + parametersOf(extensionId, playlist, true, tabId, removeIndex) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = ItemLoadingBinding.bind(view) + observe(vm.saveState) { save -> + binding.textView.text = save.toText(playlist, requireContext()) + val save = save as? EditPlaylistViewModel.SaveState.Saved ?: return@observe + if (save.result.isSuccess) parentFragmentManager.setFragmentResult( + "reload", bundleOf("id" to playlist.id) + ) + dismiss() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistFragment.kt new file mode 100644 index 0000000..5b12931 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistFragment.kt @@ -0,0 +1,143 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ConcatAdapter +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.FragmentPlaylistEditBinding +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsetsWithChild +import dev.brahmkshatriya.echo.ui.feed.TabsAdapter +import dev.brahmkshatriya.echo.ui.playlist.edit.EditPlaylistBottomSheet.Companion.toText +import dev.brahmkshatriya.echo.ui.playlist.edit.search.EditPlaylistSearchFragment +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class EditPlaylistFragment : Fragment() { + + companion object { + fun getBundle(extension: String, playlist: Playlist, loaded: Boolean) = Bundle().apply { + putString("extensionId", extension) + putSerialized("playlist", playlist) + putBoolean("loaded", loaded) + } + } + + private val args by lazy { requireArguments() } + private val extensionId by lazy { args.getString("extensionId")!! } + private val playlist by lazy { args.getSerialized("playlist")!!.getOrThrow() } + private val loaded by lazy { args.getBoolean("loaded", false) } + private val selectedTab by lazy { args.getString("selectedTabId").orEmpty() } + + private var binding: FragmentPlaylistEditBinding by autoCleared() + private val vm by viewModel { + parametersOf(extensionId, playlist, loaded, selectedTab, -1) + } + + private val adapter by lazy { + val (listener, itemCallback) = PlaylistTrackAdapter.getTouchHelperAndListener(vm) + itemCallback.attachToRecyclerView(binding.recyclerView) + PlaylistTrackAdapter(listener) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentPlaylistEditBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + applyInsetsWithChild(binding.appBarLayout, binding.recyclerView, 96) { + binding.fabContainer.applyInsets(it) + } + + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.toolbarOutline.alpha = offset + binding.toolbarIconContainer.alpha = 1 - offset + } + + binding.toolbar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + binding.toolbar.setOnMenuItemClickListener { + parentFragmentManager.setFragmentResult("delete", Bundle().apply { + putSerialized("playlist", playlist) + }) + parentFragmentManager.popBackStack() + true + } + + binding.save.setOnClickListener { + vm.save() + } + observe(vm.isSaveable) { + binding.save.isEnabled = it + } + + binding.add.setOnClickListener { + openFragment( + it, EditPlaylistSearchFragment.getBundle(extensionId) + ) + } + parentFragmentManager.setFragmentResultListener("searchedTracks", this) { _, bundle -> + val tracks = bundle.getSerialized>("tracks")!!.getOrNull().orEmpty().toMutableList() + vm.edit( + EditPlaylistViewModel.Action.Add( + vm.currentTracks.value?.size ?: 0, tracks + ) + ) + } + + FastScrollerHelper.applyTo(binding.recyclerView) + + val headerAdapter = EditPlaylistHeaderAdapter(this, vm) + val tabAdapter = TabsAdapter({ title }) { v, index, tab -> + vm.selectedTabFlow.value = tab + } + + binding.recyclerView.adapter = ConcatAdapter(headerAdapter, tabAdapter, adapter) + observe(vm.dataFlow) { headerAdapter.data = it } + observe(vm.tabsFlow) { tabAdapter.data = it } + observe(vm.selectedTabFlow) { tabAdapter.selected = vm.tabsFlow.value.indexOf(it) } + observe(vm.currentTracks) { adapter.submitList(it) } + + val combined = vm.originalList.combine(vm.saveState) { a, b -> a to b } + observe(combined) { (tracks, save) -> + val trackLoading = tracks == null + val saving = save != EditPlaylistViewModel.SaveState.Initial + val loading = trackLoading || saving + binding.recyclerView.isVisible = !loading + binding.fabContainer.isVisible = !loading + binding.loading.root.isVisible = loading + binding.loading.textView.text = save.toText(playlist, requireContext()) + + val save = save as? EditPlaylistViewModel.SaveState.Saved ?: return@observe + if (save.result.isSuccess) parentFragmentManager.setFragmentResult( + "reload", bundleOf("id" to playlist.id) + ) + parentFragmentManager.popBackStack() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistHeaderAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistHeaderAdapter.kt new file mode 100644 index 0000000..b71061a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistHeaderAdapter.kt @@ -0,0 +1,60 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.ItemEditPlaylistHeaderBinding +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadInto +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class EditPlaylistHeaderAdapter( + private val fragment: EditPlaylistFragment, + private val viewModel: EditPlaylistViewModel, +) : ScrollAnimRecyclerAdapter() { + class ViewHolder(val binding: ItemEditPlaylistHeaderBinding) : + ScrollAnimViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemEditPlaylistHeaderBinding.inflate(inflater, parent, false) + binding.playlistName.setOnEditorActionListener { v, actionId, event -> + viewModel.nameFlow.value = v.text.toString() + binding.playlistDescription.requestFocus() + true + } + binding.playlistDescription.setOnEditorActionListener { v, actionId, event -> + viewModel.descriptionFlow.value = v.text.toString() + v.clearFocus() + true + } + binding.coverContainer.setOnClickListener { + viewModel.changeCover(fragment.requireActivity()) + } + binding.removeCover.setOnClickListener { + viewModel.coverFlow.value = EditPlaylistViewModel.CoverState.Removed + } + return ViewHolder(binding) + } + + override fun getItemCount() = 1 + + var data: EditPlaylistViewModel.Data? = null + set(value) { + field = value + notifyItemChanged(0) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val binding = holder.binding + val (title, desc, coverEditable, cover) = data ?: return + binding.playlistName.setText(title) + binding.playlistDescription.setText(desc) + binding.coverContainer.isVisible = coverEditable + binding.removeCover.isVisible = cover != null + cover.loadInto(binding.cover, R.drawable.art_add_photo) + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistViewModel.kt new file mode 100644 index 0000000..9e0c1e1 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/EditPlaylistViewModel.kt @@ -0,0 +1,324 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit + +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditCoverClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditorListenerClient +import dev.brahmkshatriya.echo.common.models.Feed +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceUriImageHolder +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Tab +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getIf +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getOrThrow +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.isClient +import dev.brahmkshatriya.echo.extensions.InstallationUtils.openFileSelector +import dev.brahmkshatriya.echo.utils.CoroutineUtils.combineTransformLatest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class EditPlaylistViewModel( + extensionLoader: ExtensionLoader, + private val app: App, + private val extensionId: String, + private val initial: Playlist, + private val loaded: Boolean, + private val selectedTab: String?, + private val removeIndex: Int, +) : ViewModel() { + val extensionFlow = extensionLoader.music.map { list -> list.find { it.id == extensionId } } + .stateIn(viewModelScope, Eagerly, null) + + val playlistFlow = extensionFlow.transformLatest { extension -> + emit(null) + if (extension == null) return@transformLatest + emit(extension.getAs { + if (!loaded) loadPlaylist(initial) else initial + }) + }.stateIn(viewModelScope, Eagerly, null) + + private val feedFlow = playlistFlow.transformLatest { playlist -> + emit(null) + val extension = extensionFlow.value ?: return@transformLatest + if (playlist == null) return@transformLatest + emit(extension.getAs> { + loadTracks(playlist.getOrThrow()) + }) + } + + val selectedTabFlow = MutableStateFlow(null) + val tabsFlow = feedFlow.map { result -> + val tabs = result?.getOrNull()?.tabs ?: emptyList() + selectedTabFlow.value = tabs.find { it.id == selectedTab } ?: tabs.firstOrNull() + tabs + }.stateIn(viewModelScope, Eagerly, emptyList()) + + val originalList = feedFlow.combineTransformLatest(selectedTabFlow) { result, tab -> + emit(null) + if (result == null) return@combineTransformLatest + val extension = extensionFlow.value ?: return@combineTransformLatest + val tracks = extension.getAs> { + result.getOrThrow().getPagedData(tab).pagedData.loadAll() + } + emit(tracks) + }.stateIn(viewModelScope, Eagerly, null) + + val currentTracks = MutableStateFlow?>(null) + fun edit(action: Action) { + currentTracks.value = currentTracks.value?.toMutableList()?.apply { + runCatching { + when (action) { + is Action.Add -> addAll(action.index, action.items) + is Action.Move -> add(action.to, removeAt(action.from)) + is Action.Remove -> action.indexes.forEach { removeAt(it) } + } + }.getOrElse { + viewModelScope.launch { app.throwFlow.emit(it) } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val newActions = currentTracks.transformLatest { current -> + emit(null) + val list = originalList.value?.getOrNull() + if (list == null || current == null) return@transformLatest + emit(computeActions(list, current)) + }.flowOn(Dispatchers.IO).stateIn(viewModelScope, Eagerly, null) + + data class Data( + val title: String, + val desc: String?, + val coverEditable: Boolean, + val cover: ImageHolder?, + ) + + val nameFlow = MutableStateFlow(initial.title) + val descriptionFlow = MutableStateFlow(initial.description) + + sealed interface CoverState { + data object Initial : CoverState + data object Removed : CoverState + data class Changed(val file: File) : CoverState + } + + val coverFlow = MutableStateFlow(CoverState.Initial) + val dataFlow = combineTransformLatest(nameFlow, descriptionFlow, coverFlow, extensionFlow) { + val title = it[0] as String + val desc = it[1] as String? + val cover = it[2] as CoverState + val extension = it[3] as Extension<*>? + emit(Data(title, desc, false, null)) + val isEditable = extension?.isClient() == true + val image = when (cover) { + is CoverState.Changed -> cover.file.toUri().toString().toResourceUriImageHolder() + CoverState.Initial -> initial.cover + CoverState.Removed -> null + } + emit(Data(title, desc, isEditable, image)) + } + + sealed interface SaveState { + data object Initial : SaveState + data class Performing(val action: Action, val tracks: List) : SaveState + data object Saving : SaveState + data class Saved(val result: Result) : SaveState + } + + private val saveFlow = MutableSharedFlow() + val isSaveable = newActions.combine(dataFlow) { actions, pair -> + val playlist = playlistFlow.value?.getOrNull() ?: return@combine false + if (playlist.title != pair.title) return@combine true + if (playlist.description != pair.desc) return@combine true + if (coverFlow.value != CoverState.Initial) return@combine true + !actions.isNullOrEmpty() + } + + val saveState = saveFlow.transformLatest { + emit(SaveState.Saving) + val saved = SaveState.Saved(runCatching { + val playlist = playlistFlow.value!!.getOrThrow() + val extension = extensionFlow.value!! + if (playlist.title != nameFlow.value || playlist.description != descriptionFlow.value) { + extension.getAs { + editPlaylistMetadata(playlist, nameFlow.value, descriptionFlow.value) + }.getOrThrow() + } + val cover = coverFlow.value + when (cover) { + CoverState.Initial -> {} + CoverState.Removed -> extension.getAs { + editPlaylistCover(playlist, null) + } + + is CoverState.Changed -> extension.getAs { + editPlaylistCover(playlist, cover.file) + } + } + + val newActions = newActions.value!! + if (newActions.isEmpty()) return@runCatching + + var tracks = originalList.value!!.getOrThrow() + val selectedTab = selectedTabFlow.value + extension.getIf { + onEnterPlaylistEditor(playlist, tracks) + }.getOrThrow() + + extension.getAs { + newActions.forEach { action -> + when (action) { + is Action.Add -> { + addTracksToPlaylist(playlist, tracks, action.index, action.items) + tracks = + loadTracks(playlist).run { getPagedData(selectedTab).pagedData } + .loadAll() + } + + is Action.Move -> { + moveTrackInPlaylist(playlist, tracks, action.from, action.to) + tracks = tracks.toMutableList().apply { + add(action.to, removeAt(action.from)) + } + } + + is Action.Remove -> { + removeTracksFromPlaylist(playlist, tracks, action.indexes) + tracks = + loadTracks(playlist).run { getPagedData(selectedTab).pagedData } + .loadAll() + } + } + } + }.getOrThrow() + extension.getIf { + onExitPlaylistEditor(playlist, tracks) + }.getOrThrow() + }) + saved.result.getOrThrow(app.throwFlow) + emit(saved) + }.stateIn(viewModelScope, Eagerly, SaveState.Initial) + + fun save() = viewModelScope.launch { saveFlow.emit(Unit) } + fun changeCover(activity: FragmentActivity) = viewModelScope.launch { + runCatching { + coverFlow.value = CoverState.Changed(activity.openFileSelector(fileType = "image/*")) + }.getOrThrow(app.throwFlow) + } + + init { + viewModelScope.launch { + originalList.collectLatest { + currentTracks.value = it?.getOrNull() + } + } + viewModelScope.launch { + if (removeIndex == -1) return@launch + currentTracks.first { it != null } + edit(Action.Remove(listOf(removeIndex))) + newActions.first { it != null } + saveFlow.emit(Unit) + } + } + + sealed interface Action { + data class Add(val index: Int, val items: MutableList) : Action + data class Remove(val indexes: List) : Action + data class Move(val from: Int, val to: Int) : Action + } + + companion object { + // I am KR bbg. + // Don't forget to subscribe to my OnlyFans at https://github.com/justfoolingaround + fun computeActions( + old: List, new: List, + ): MutableList> { + val out = mutableListOf>() + val before = old.toMutableList() + + val afterIds = new.map { it }.toSet() + val removeIndexes = before.mapIndexedNotNull { index, item -> + if (item !in afterIds) index else null + } + + if (removeIndexes.isNotEmpty()) { + removeIndexes.sortedDescending().forEach { before.removeAt(it) } + out.add(Action.Remove(indexes = removeIndexes)) + } + + val additions = mutableListOf() + var addIndex = -1 + new.forEachIndexed { targetIndex, targetItem -> + if (before.none { it == targetItem }) { + if (addIndex == -1) addIndex = targetIndex + additions.add(targetItem) + before.add(targetIndex, targetItem) + } else if (additions.isNotEmpty()) { + out.add(Action.Add(index = addIndex, items = additions.toMutableList())) + additions.clear() + addIndex = -1 + } + } + if (additions.isNotEmpty()) { + out.add(Action.Add(index = addIndex, items = additions)) + } + + val moveActions = mutableListOf>() + new.forEachIndexed { targetIndex, targetItem -> + val currentIndex = before.indexOfFirst { it == targetItem } + if (currentIndex != -1 && currentIndex != targetIndex) { + moveActions.add(Action.Move(from = currentIndex, to = targetIndex)) + val item = before.removeAt(currentIndex) + before.add(targetIndex, item) + } + } + + var ind = -1 + var last: Action.Move? = null + moveActions.forEach { + val l = last + if (l != null) { + if (l.from == it.to) { + if (ind == -1) ind = l.from - 1 + } else { + if (ind == -1) out.add(l) + else { + out.add(Action.Move(from = ind, to = l.from)) + ind = -1 + } + } + } + last = it + } + val l = last + if (l != null) { + if (ind != -1) out.add(Action.Move(from = ind, to = l.from)) + else out.add(l) + } + + return out + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/PlaylistTrackAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/PlaylistTrackAdapter.kt new file mode 100644 index 0000000..624853c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/PlaylistTrackAdapter.kt @@ -0,0 +1,118 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.view.updatePaddingRelative +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.ItemPlaylistTrackBinding +import dev.brahmkshatriya.echo.ui.player.more.upnext.QueueAdapter.Companion.bind +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class PlaylistTrackAdapter( + private val listener: Listener, +) : ScrollAnimListAdapter(DiffCallback) { + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Track, newItem: Track) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Track, newItem: Track) = oldItem == newItem + } + + interface Listener { + fun onTrackClicked(viewHolder: ViewHolder) + fun onTrackClosedClicked(viewHolder: ViewHolder) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent, listener) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val track = getItem(position) + holder.bind(track) + } + + class ViewHolder( + parent: ViewGroup, + listener: Listener, + val binding: ItemPlaylistTrackBinding = ItemPlaylistTrackBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + var track: Track? = null + + init { + binding.playlistItemClose.setOnClickListener { + listener.onTrackClosedClicked(this) + } + binding.playlistItemDrag.setOnTouchListener { v, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + v.performClick() + listener.onTrackClicked(this) + } + true + } + val color = MaterialColors.getColor(binding.root, R.attr.echoBackground) + binding.root.backgroundTintList = ColorStateList.valueOf(color) + binding.playlistItemNowPlaying.isVisible = false + binding.playlistItem.updatePaddingRelative(start = 24.dpToPx(binding.root.context)) + } + + fun bind(track: Track) { + this.track = track + binding.bind(track) + } + } + + companion object { + fun getTouchHelperAndListener( + viewModel: EditPlaylistViewModel + ): Pair { + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START + ) { + override fun getMovementFlags( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ): Int { + if (viewHolder !is ViewHolder) return 0 + return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) + } + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + if (viewHolder !is ViewHolder) return false + if (target !is ViewHolder) return false + + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + viewModel.edit(EditPlaylistViewModel.Action.Move(fromPos, toPos)) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val pos = viewHolder.bindingAdapterPosition + viewModel.edit(EditPlaylistViewModel.Action.Remove(listOf(pos))) + } + } + val itemTouchHelper = ItemTouchHelper(callback) + val listener = object : Listener { + override fun onTrackClicked(viewHolder: ViewHolder) { + itemTouchHelper.startDrag(viewHolder) + } + + override fun onTrackClosedClicked(viewHolder: ViewHolder) { + viewModel.edit(EditPlaylistViewModel.Action.Remove(listOf(viewHolder.bindingAdapterPosition))) + } + } + return listener to itemTouchHelper + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchClickListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchClickListener.kt new file mode 100644 index 0000000..83ef807 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchClickListener.kt @@ -0,0 +1,40 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit.search + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.feed.FeedClickListener + +class EditPlaylistSearchClickListener(fragment: Fragment) : FeedClickListener( + fragment, fragment.parentFragmentManager, R.id.playlistSearchContainer +) { + + private val parentFragment: Fragment + get() = fragmentManager.fragments.first().requireParentFragment() + + private val viewModel by lazy { + parentFragment.viewModels().value + } + + override fun onTracksClicked( + view: View?, extensionId: String?, context: EchoMediaItem?, tracks: List?, pos: Int + ): Boolean { + val track = tracks?.getOrNull(pos) ?: return notFoundSnack(R.string.track) + viewModel.addTrack(track) + return true + } + + override fun onMediaClicked( + view: View?, extensionId: String?, item: EchoMediaItem?, context: EchoMediaItem? + ) = when (item) { + is Track -> { + viewModel.addTrack(item) + true + } + + else -> super.onMediaClicked(view, extensionId, item, context) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchFragment.kt new file mode 100644 index 0000000..013bfb3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchFragment.kt @@ -0,0 +1,118 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.BackEventCompat +import androidx.activity.OnBackPressedCallback +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.databinding.FragmentPlaylistSearchBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.main.search.SearchFragment +import dev.brahmkshatriya.echo.ui.playlist.SelectableMediaAdapter +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx + +class EditPlaylistSearchFragment : Fragment() { + companion object { + fun getBundle(extensionId: String) = Bundle().apply { + putString("extensionId", extensionId) + } + } + + private var binding by autoCleared() + private val viewModel by viewModels() + + private val args by lazy { requireArguments() } + private val extensionId by lazy { args.getString("extensionId")!! } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = FragmentPlaylistSearchBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + val behavior = BottomSheetBehavior.from(binding.bottomSheet) + binding.bottomSheetDragHandle.setOnClickListener { behavior.state = STATE_EXPANDED } + var topInset = 0 + applyInsets { + topInset = it.top + behavior.peekHeight = 72.dpToPx(requireContext()) + it.bottom + binding.playlistSearchContainer.updatePadding(bottom = it.bottom) + binding.recyclerView.updatePadding(top = it.top) + } + + val backCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackStarted(backEvent: BackEventCompat) = + behavior.startBackProgress(backEvent) + + override fun handleOnBackProgressed(backEvent: BackEventCompat) = + behavior.updateBackProgress(backEvent) + + override fun handleOnBackPressed() = behavior.handleBackInvoked() + override fun handleOnBackCancelled() = behavior.cancelBackProgress() + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback) + + behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(p0: View, p1: Int) { + backCallback.isEnabled = p1 == STATE_EXPANDED + } + + override fun onSlide(p0: View, p1: Float) { + binding.selectedSongsLayout.translationY = p1 * topInset + binding.recyclerView.alpha = p1 + binding.bottomSheetDragHandle.alpha = 1 - p1 + } + }) + + val searchFragment = binding.playlistSearchContainer.getFragment() + searchFragment.arguments = Bundle().apply { + putString("extensionId", extensionId) + putString("feedListener", "playlist_search") + } + searchFragment.parentFragmentManager.addFragmentOnAttachListener { _, fragment -> + val arguments = fragment.arguments ?: Bundle() + arguments.putAll(searchFragment.arguments) + fragment.arguments = arguments + } + + val adapter = SelectableMediaAdapter { _, item -> + viewModel.toggleTrack(item as Track) + } + configureGridLayout(binding.recyclerView, adapter, false) + binding.addTracks.setOnClickListener { + parentFragmentManager.setFragmentResult("searchedTracks", Bundle().apply { + putSerialized("tracks", viewModel.selectedTracks.value) + }) + viewModel.selectedTracks.value = emptyList() + parentFragmentManager.popBackStack() + } + + observe(viewModel.selectedTracks) { list -> + val items = list.map { + it to (it in viewModel.selectedTracks.value) + } + adapter.submitList(items) + binding.addTracks.isEnabled = items.isNotEmpty() + val tracks = items.size + binding.selectedSongs.text = runCatching { + resources.getQuantityString(R.plurals.number_songs, tracks, tracks) + }.getOrNull() ?: getString(R.string.n_songs, tracks) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchViewModel.kt new file mode 100644 index 0000000..445033b --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/edit/search/EditPlaylistSearchViewModel.kt @@ -0,0 +1,22 @@ +package dev.brahmkshatriya.echo.ui.playlist.edit.search + +import androidx.lifecycle.ViewModel +import dev.brahmkshatriya.echo.common.models.Track +import kotlinx.coroutines.flow.MutableStateFlow + +class EditPlaylistSearchViewModel : ViewModel() { + + val selectedTracks = MutableStateFlow>(listOf()) + fun toggleTrack(track: Track) = with(selectedTracks) { + val mutable = selectedTracks.value.toMutableList() + with(mutable) { if (contains(track)) remove(track) else add(track) } + value = mutable + } + + fun addTrack(track: Track) = with(selectedTracks) { + if(value.contains(track)) return@with + val mutable = selectedTracks.value.toMutableList() + mutable.add(track) + value = mutable + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/MediaItemAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/MediaItemAdapter.kt new file mode 100644 index 0000000..f333de7 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/MediaItemAdapter.kt @@ -0,0 +1,66 @@ +package dev.brahmkshatriya.echo.ui.playlist.save + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.databinding.ItemShelfMediaBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.feed.viewholders.MediaViewHolder.Companion.bind +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimListAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class MediaItemAdapter( + private val listener: Listener, +) : ScrollAnimListAdapter( + DiffCallback +), GridAdapter { + + data class Item(val extensionId: String, val item: EchoMediaItem) { + val id = extensionId + item.id + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem + } + + fun interface Listener { + fun onMediaItemClicked(view: View?, item: Item?) + } + + class ViewHolder( + parent: ViewGroup, + private val listener: Listener, + val binding: ItemShelfMediaBinding = ItemShelfMediaBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ScrollAnimViewHolder(binding.root) { + var item: Item? = null + + init { + binding.coverContainer.cover.clipToOutline = true + binding.root.setOnClickListener { + listener.onMediaItemClicked(it, item) + } + binding.more.isVisible = false + } + + fun bind(item: Item) { + this.item = item + binding.bind(item.item) + binding.play.isVisible = false + } + } + + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent, listener) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val item = getItem(position) ?: return + holder.bind(item) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistBottomSheet.kt new file mode 100644 index 0000000..523ffa0 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistBottomSheet.kt @@ -0,0 +1,127 @@ +package dev.brahmkshatriya.echo.ui.playlist.save + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.databinding.DialogPlaylistSaveBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.ui.common.GridAdapter.Companion.configureGridLayout +import dev.brahmkshatriya.echo.ui.playlist.SelectableMediaAdapter +import dev.brahmkshatriya.echo.ui.playlist.create.CreatePlaylistBottomSheet +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.Serializer.getSerialized +import dev.brahmkshatriya.echo.utils.Serializer.putSerialized +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureBottomBar +import kotlinx.coroutines.flow.combine +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class SaveToPlaylistBottomSheet : BottomSheetDialogFragment() { + + companion object { + fun newInstance(extensionId: String, item: EchoMediaItem) = + SaveToPlaylistBottomSheet().apply { + arguments = Bundle().apply { + putString("extensionId", extensionId) + putSerialized("item", item) + } + } + } + + private val args by lazy { requireArguments() } + private val extensionId by lazy { args.getString("extensionId")!! } + private val item by lazy { args.getSerialized("item")!!.getOrThrow() } + + private val itemAdapter by lazy { + MediaItemAdapter { _, _ -> } + } + + private val adapter by lazy { + SelectableMediaAdapter { _, item -> + viewModel.togglePlaylist(item as Playlist) + } + } + + private val topBarAdapter by lazy { + TopAppBarAdapter( + { dismiss() }, + { CreatePlaylistBottomSheet().show(parentFragmentManager, null) } + ) + } + + private var binding by autoCleared() + private val viewModel by viewModel { + parametersOf(extensionId, item) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, + ): View { + binding = DialogPlaylistSaveBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + configureBottomBar(binding.saveCont) + binding.save.setOnClickListener { + viewModel.saveTracks() + } + itemAdapter.submitList(listOf(MediaItemAdapter.Item(extensionId, item))) + val combined = viewModel.playlistsFlow.combine(viewModel.saveFlow) { playlists, save -> + playlists to save + } + configureGridLayout( + binding.recyclerView, + GridAdapter.Concat( + topBarAdapter, + itemAdapter, + adapter.withHeader { viewModel.toggleAll(it) }, + ), + false + ) + observe(combined) { (state, save) -> + val playlistLoading = state !is SaveToPlaylistViewModel.PlaylistState.Loaded + val saving = save != SaveToPlaylistViewModel.SaveState.Initial + val loading = playlistLoading || saving + binding.recyclerView.isVisible = !saving + binding.loading.root.isVisible = loading + binding.loading.textView.text = when (save) { + SaveToPlaylistViewModel.SaveState.Initial -> getString(R.string.loading) + is SaveToPlaylistViewModel.SaveState.LoadingPlaylist -> + getString(R.string.loading_x, save.playlist.title) + + SaveToPlaylistViewModel.SaveState.LoadingTracks -> getString( + R.string.loading_x, + item.title + ) + + is SaveToPlaylistViewModel.SaveState.Saved -> { + dismiss() + getString(R.string.not_loading) + } + + is SaveToPlaylistViewModel.SaveState.Saving -> + getString(R.string.saving_x, save.playlist.title) + } + if (state is SaveToPlaylistViewModel.PlaylistState.Loaded) { + if (state.list == null) { + dismiss() + return@observe + } + adapter.submitList(state.list) + binding.save.isEnabled = state.list.any { it.second } + } + } + + parentFragmentManager.setFragmentResultListener( + "createPlaylist", this + ) { _, _ -> viewModel.refresh() } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistViewModel.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistViewModel.kt new file mode 100644 index 0000000..bd2b867 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/SaveToPlaylistViewModel.kt @@ -0,0 +1,151 @@ +package dev.brahmkshatriya.echo.ui.playlist.save + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.clients.AlbumClient +import dev.brahmkshatriya.echo.common.clients.PlaylistClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditClient +import dev.brahmkshatriya.echo.common.clients.PlaylistEditorListenerClient +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.models.Album +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Feed.Companion.loadAll +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Radio +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getAs +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtension +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.getExtensionOrThrow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class SaveToPlaylistViewModel( + private val extensionId: String, + private val item: EchoMediaItem, + private val app: App, + extensionLoader: ExtensionLoader, +) : ViewModel() { + + private val extensions = extensionLoader.music + private val extensionFlow = MutableStateFlow?>(null) + + sealed class SaveState { + data object Initial : SaveState() + data object LoadingTracks : SaveState() + data class LoadingPlaylist(val playlist: Playlist) : SaveState() + data class Saving(val playlist: Playlist, val tracks: List) : SaveState() + data class Saved(val success: Boolean) : SaveState() + } + + val saveFlow = MutableStateFlow(SaveState.Initial) + fun saveTracks() = viewModelScope.launch(Dispatchers.IO) { + saveFlow.value = SaveState.LoadingTracks + val result = runCatching { + val extension = extensionFlow.value!! + val playlists = when (val state = playlistsFlow.value) { + is PlaylistState.Loaded -> state.list!!.mapNotNull { if (it.second) it.first else null } + else -> throw IllegalStateException("Playlists not loaded") + } + if (playlists.isEmpty()) return@runCatching false + saveFlow.value = SaveState.LoadingTracks + val tracks = when (item) { + is Album -> + extension.getAs> { loadTracks(item)?.loadAll().orEmpty() } + + is Playlist -> + extension.getAs> { loadTracks(item).loadAll() } + + is Radio -> + extension.getAs> { loadTracks(item).loadAll() } + + is Track -> Result.success(listOf(item)) + else -> null + }?.getOrThrow().orEmpty() + if (tracks.isEmpty()) return@runCatching false + + playlists.forEach { playlist -> + extension.getAs { + saveFlow.value = SaveState.LoadingPlaylist(playlist) + val loaded = loadPlaylist(playlist) + check(loaded.isEditable) + val playlistTracks = loadTracks(loaded).loadAll() + saveFlow.value = SaveState.Saving(loaded, tracks) + val listener = this as? PlaylistEditorListenerClient + listener?.onEnterPlaylistEditor(loaded, playlistTracks) + addTracksToPlaylist(loaded, playlistTracks, playlistTracks.size, tracks) + listener?.onExitPlaylistEditor(loaded, playlistTracks + tracks) + }.getOrThrow() + } + val message = + if (playlists.size != 1) app.context.getString(R.string.saved_to_playlists) + else app.context.getString(R.string.saved_to_x, playlists.first().title) + app.messageFlow.emit(Message(message)) + true + }.getOrElse { + app.throwFlow.emit(it) + false + } + saveFlow.value = SaveState.Saved(result) + } + + sealed class PlaylistState { + data object Initial : PlaylistState() + data object Loading : PlaylistState() + data class Loaded(val list: List>?) : PlaylistState() + } + + val playlistsFlow = MutableStateFlow(PlaylistState.Initial) + private suspend fun loadPlaylists(): List> { + val extension = extensions.getExtensionOrThrow(extensionId) + val track = item as? Track + return extension.getAs>> { + listEditablePlaylists(track) + }.getOrThrow() + } + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + playlistsFlow.value = PlaylistState.Loading + val result = runCatching { loadPlaylists() }.getOrElse { + app.throwFlow.emit(it) + null + } + playlistsFlow.value = PlaylistState.Loaded(result) + } + } + + fun togglePlaylist(playlist: Playlist) { + val state = playlistsFlow.value + if (state !is PlaylistState.Loaded) return + val newList = state.list?.toMutableList() ?: return + val index = newList.indexOfFirst { it.first.id == playlist.id } + if (index == -1) return + newList[index] = newList[index].copy(second = !newList[index].second) + playlistsFlow.value = PlaylistState.Loaded(newList) + } + + fun toggleAll(selected: Boolean) { + val state = playlistsFlow.value + if (state !is PlaylistState.Loaded) return + val newList = state.list?.map { it.copy(second = selected) } ?: return + playlistsFlow.value = PlaylistState.Loaded(newList) + } + + init { + viewModelScope.launch { + extensions.collectLatest { + extensionFlow.value = extensions.getExtension(extensionId) + } + } + viewModelScope.launch(Dispatchers.IO) { + refresh() + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/TopAppBarAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/TopAppBarAdapter.kt new file mode 100644 index 0000000..8539b96 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/playlist/save/TopAppBarAdapter.kt @@ -0,0 +1,38 @@ +package dev.brahmkshatriya.echo.ui.playlist.save + +import android.view.LayoutInflater +import android.view.ViewGroup +import dev.brahmkshatriya.echo.databinding.ItemPlaylistSaveHeaderBinding +import dev.brahmkshatriya.echo.ui.common.GridAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimRecyclerAdapter +import dev.brahmkshatriya.echo.utils.ui.scrolling.ScrollAnimViewHolder + +class TopAppBarAdapter( + private val onClose: () -> Unit, + private val onCreateClicked: () -> Unit +) : ScrollAnimRecyclerAdapter(), GridAdapter { + inner class ViewHolder( + val binding: ItemPlaylistSaveHeaderBinding + ) : ScrollAnimViewHolder(binding.root) { + init { + binding.root.setNavigationOnClickListener { + onClose() + } + + binding.root.setOnMenuItemClickListener { + onCreateClicked() + true + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return ViewHolder(ItemPlaylistSaveHeaderBinding.inflate(inflater, parent, false)) + + } + + override fun getItemCount() = 1 + override val adapter = this + override fun getSpanSize(position: Int, width: Int, count: Int) = count +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioEffectsFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioEffectsFragment.kt new file mode 100644 index 0000000..432fafd --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioEffectsFragment.kt @@ -0,0 +1,79 @@ +package dev.brahmkshatriya.echo.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.FragmentAudioFxBinding +import dev.brahmkshatriya.echo.databinding.FragmentGenericCollapsableBinding +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.deleteGlobalFx +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.globalFx +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.ui.extensions.login.LoginFragment.Companion.bind +import dev.brahmkshatriya.echo.ui.player.audiofx.AudioEffectsBottomSheet.Companion.bind +import dev.brahmkshatriya.echo.ui.player.audiofx.AudioEffectsBottomSheet.Companion.onEqualizerClicked +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper + +class AudioEffectsFragment : Fragment() { + + private var binding: FragmentGenericCollapsableBinding by autoCleared() + private val fragment = AudioFxFragment() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentGenericCollapsableBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.bind(this) + binding.extensionIcon.isVisible = false + binding.toolBar.title = getString(R.string.audio_fx) + childFragmentManager.beginTransaction().replace(R.id.genericFragmentContainer, fragment) + .commit() + + binding.toolBar.inflateMenu(R.menu.refresh_menu) + binding.toolBar.setOnMenuItemClickListener { + val context = requireContext() + context.deleteGlobalFx() + fragment.bind() + true + } + } + + class AudioFxFragment : Fragment() { + var binding by autoCleared() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentAudioFxBinding.inflate(inflater, container, false) + return binding.root + } + + fun bind() = binding.bind(requireContext().globalFx()) { onEqualizerClicked() } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + bind() + binding.root.apply { + clipToPadding = false + applyInsets { applyContentInsets(it) } + isVerticalScrollBarEnabled = false + FastScrollerHelper.applyTo(this) + } + } + } + + companion object { + const val AUDIO_FX = "audio_fx" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/BaseSettingsFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/BaseSettingsFragment.kt new file mode 100644 index 0000000..cd46c83 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/BaseSettingsFragment.kt @@ -0,0 +1,70 @@ +package dev.brahmkshatriya.echo.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImageHolder +import dev.brahmkshatriya.echo.databinding.FragmentGenericCollapsableBinding +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyBackPressCallback +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyContentInsets +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.applyInsets +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircle +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.setupTransition +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureAppBar + +abstract class BaseSettingsFragment : Fragment() { + + abstract val title: String + abstract val icon: ImageHolder? + abstract val creator: () -> Fragment + + var binding: FragmentGenericCollapsableBinding by autoCleared() + + final override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentGenericCollapsableBinding.inflate(inflater, container, false) + return binding.root + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupTransition(view) + + applyBackPressCallback() + binding.appBarLayout.configureAppBar { offset -> + binding.toolbarOutline.alpha = offset + binding.extensionIcon.alpha = 1 - offset + } + binding.toolBar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + binding.toolBar.title = title + icon.loadAsCircle(binding.extensionIcon, R.drawable.ic_extension_32dp) { + binding.extensionIcon.setImageDrawable(it) + } + + childFragmentManager.beginTransaction().replace(R.id.genericFragmentContainer, creator()) + .commit() + } + + companion object { + fun PreferenceFragmentCompat.configure() { + listView?.apply { + clipToPadding = false + applyInsets { applyContentInsets(it) } + isVerticalScrollBarEnabled = false + FastScrollerHelper.applyTo(this) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsBottomSheet.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsBottomSheet.kt new file mode 100644 index 0000000..91de210 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsBottomSheet.kt @@ -0,0 +1,245 @@ +package dev.brahmkshatriya.echo.ui.settings + +import android.graphics.drawable.Animatable +import android.os.Build.BRAND +import android.os.Build.DEVICE +import android.os.Build.VERSION.CODENAME +import android.os.Build.VERSION.RELEASE +import android.os.Build.VERSION.SDK_INT +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.MainApplication.Companion.getCurrentLanguage +import dev.brahmkshatriya.echo.MainApplication.Companion.languages +import dev.brahmkshatriya.echo.MainApplication.Companion.setCurrentLanguage +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ExtensionType +import dev.brahmkshatriya.echo.databinding.DialogSettingsBinding +import dev.brahmkshatriya.echo.extensions.db.models.UserEntity.Companion.toEntity +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.download.DownloadFragment +import dev.brahmkshatriya.echo.ui.download.DownloadViewModel +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoFragment +import dev.brahmkshatriya.echo.ui.extensions.ExtensionInfoFragment.Companion.openLink +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.ui.extensions.add.ExtensionsAddBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.list.ExtensionsListBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListBottomSheet +import dev.brahmkshatriya.echo.ui.extensions.login.LoginUserListViewModel +import dev.brahmkshatriya.echo.ui.extensions.manage.ManageExtensionsFragment +import dev.brahmkshatriya.echo.ui.main.HeaderAdapter.Companion.loadBigIcon +import dev.brahmkshatriya.echo.ui.main.HeaderAdapter.Companion.setLoopedLongClick +import dev.brahmkshatriya.echo.utils.ContextUtils.appVersion +import dev.brahmkshatriya.echo.utils.ContextUtils.copyToClipboard +import dev.brahmkshatriya.echo.utils.ContextUtils.getArch +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.ContextUtils.observe +import dev.brahmkshatriya.echo.utils.ui.UiUtils.configureBottomBar +import org.koin.androidx.viewmodel.ext.android.activityViewModel +import kotlin.random.Random + +class SettingsBottomSheet : BottomSheetDialogFragment(R.layout.dialog_settings) { + private val viewModel by activityViewModel() + private val downloadVM by activityViewModel() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = DialogSettingsBinding.bind(view) + + binding.closeButton.setOnClickListener { dismiss() } + binding.logoImage.setOnClickListener { + binding.logoImage.setImageResource(R.drawable.art_splash_anim) + (binding.logoImage.drawable as Animatable).start() + when (Random.nextInt(5)) { + 0 -> Toast.makeText(it.context, "Ooo what do we have here?", Toast.LENGTH_SHORT) + .show() + + 2 -> Toast.makeText(it.context, "Nothing to see here.", Toast.LENGTH_SHORT).show() + } + } + (binding.logoImage.drawable as Animatable).start() + + binding.player.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + binding.lookAndFeel.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + binding.other.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + binding.downloads.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + observe(downloadVM.downloadExtensions) { extensions -> + val extension = extensions?.firstOrNull() + binding.downloadSettings.isEnabled = extension != null + binding.downloadSettings.setOnClickListener { + dismiss() + extension ?: return@setOnClickListener + requireActivity().openFragment( + null, ExtensionInfoFragment.getBundle(extension) + ) + } + } + + binding.manageExtension.setOnClickListener { + dismiss() + requireActivity().openFragment() + } + + binding.addExtension.setOnClickListener { + dismiss() + ExtensionsAddBottomSheet().show(parentFragmentManager, null) + } + + val settings = requireContext().getSettings() + val language = getCurrentLanguage(settings) + val languages = mapOf("system" to getString(R.string.system)) + languages + val langList = languages.entries.toList() + binding.language.run { + text = getString(R.string.language_x, languages[language]) + setOnClickListener { + MaterialAlertDialogBuilder(context) + .setSingleChoiceItems( + langList.map { it.value }.toTypedArray(), + langList.indexOfFirst { it.key == language } + ) { dialog, which -> + setCurrentLanguage(settings, langList[which].key) + dialog.dismiss() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setTitle(getString(R.string.select_language)) + .create() + .show() + dismiss() + } + } + + binding.wiki.setOnClickListener { + dismiss() + requireActivity().openLink("https://wotaku.wiki/guides/music/echo") + } + + val repo = getString(R.string.app_github_repo) + binding.contributors.setOnClickListener { + dismiss() + requireActivity().openLink("https://github.com/$repo/graphs/contributors") + } + + binding.donate.setOnClickListener { + dismiss() + requireActivity().openLink("https://ko-fi.com/brahmkshatriya") + } + + binding.discord.setOnClickListener { + dismiss() + requireActivity().openLink("https://discord.gg/J3WvbBUU8Z") + } + + binding.github.setOnClickListener { + dismiss() + requireActivity().openLink("https://github.com/$repo") + } + + binding.telegram.setOnClickListener { + dismiss() + requireActivity().openLink("https://t.me/echo_extension") + } + + binding.version.run { + val version = appVersion() + text = version + setOnClickListener { + val info = buildString { + appendLine("Echo Version: $version") + appendLine("Device: $BRAND $DEVICE") + appendLine("Architecture: ${getArch()}") + appendLine("OS Version: $CODENAME $RELEASE ($SDK_INT)") + } + context.copyToClipboard(getString(R.string.version), info) + } + setOnLongClickListener { + val viewModel by activityViewModel() + viewModel.update(requireActivity(), true) + dismiss() + true + } + } + + binding.shivam.setOnClickListener { + dismiss() + requireActivity().openLink("https://github.com/brahmkshatriya") + } + + configureBottomBar(binding.extensionBar) + binding.extensionsCont.setOnClickListener { + dismiss() + ExtensionsListBottomSheet.newInstance(ExtensionType.MUSIC) + .show(parentFragmentManager, null) + } + + observe(viewModel.extensionLoader.current) { ext -> + viewModel.currentExtension.value = ext + binding.currentExtensionSettings.setOnClickListener { + ext ?: return@setOnClickListener + dismiss() + requireActivity().openFragment( + null, ExtensionInfoFragment.getBundle(ext) + ) + } + binding.extensions.loadBigIcon(ext?.metadata?.icon, R.drawable.ic_extension_32dp) + binding.extensionsCont.setLoopedLongClick( + viewModel.extensionLoader.music.value.filter { it.isEnabled }, + { viewModel.extensionLoader.current.value } + ) { + viewModel.extensionLoader.setupMusicExtension(it, true) + } + } + + binding.currentAccountName.setOnClickListener { + dismiss() + LoginUserListBottomSheet().show(parentFragmentManager, null) + } + binding.accountsCont.setOnClickListener { + dismiss() + LoginUserListBottomSheet().show(parentFragmentManager, null) + } + observe(viewModel.allUsersWithClient) { (ext, isLoginClient, all) -> + binding.extensionBar.updateLayoutParams { + width = if (isLoginClient) MATCH_PARENT else WRAP_CONTENT + } + binding.currentAccountName.run { + isVisible = isLoginClient + text = viewModel.currentUser.value?.name ?: getString(R.string.no_account) + setLoopedLongClick(all, { all.find { it.second } }) { + ext ?: return@setLoopedLongClick + viewModel.setLoginUser(it.first.toEntity(ext.type, ext.id)) + } + } + binding.accounts.loadBigIcon( + viewModel.currentUser.value?.cover, R.drawable.ic_account_circle_32dp + ) + binding.accountsCont.run { + isVisible = isLoginClient + setLoopedLongClick(all, { all.find { it.second } }) { + ext ?: return@setLoopedLongClick + viewModel.setLoginUser(it.first.toEntity(ext.type, ext.id)) + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsLookFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsLookFragment.kt new file mode 100644 index 0000000..4e48fa8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsLookFragment.kt @@ -0,0 +1,263 @@ +package dev.brahmkshatriya.echo.ui.settings + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import dev.brahmkshatriya.echo.MainActivity +import dev.brahmkshatriya.echo.MainActivity.Companion.AMOLED_KEY +import dev.brahmkshatriya.echo.MainActivity.Companion.BACK_ANIM +import dev.brahmkshatriya.echo.MainActivity.Companion.BIG_COVER +import dev.brahmkshatriya.echo.MainActivity.Companion.COLOR_KEY +import dev.brahmkshatriya.echo.MainActivity.Companion.CUSTOM_THEME_KEY +import dev.brahmkshatriya.echo.MainActivity.Companion.THEME_KEY +import dev.brahmkshatriya.echo.MainActivity.Companion.defaultColor +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceImageHolder +import dev.brahmkshatriya.echo.playback.MediaItemUtils.SHOW_BACKGROUND +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.BACKGROUND_GRADIENT +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.NAVBAR_GRADIENT +import dev.brahmkshatriya.echo.ui.player.PlayerFragment.Companion.DYNAMIC_PLAYER +import dev.brahmkshatriya.echo.utils.ContextUtils.SETTINGS_NAME +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.ANIMATIONS_KEY +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.SCROLL_ANIMATIONS_KEY +import dev.brahmkshatriya.echo.utils.ui.FastScrollerHelper.SCROLL_BAR +import dev.brahmkshatriya.echo.utils.ui.prefs.ColorListPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialListPreference + +class SettingsLookFragment : BaseSettingsFragment() { + override val title get() = getString(R.string.look_and_feel) + override val icon get() = R.drawable.ic_palette.toResourceImageHolder() + override val creator = { LookPreference() } + + class LookPreference : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + configure() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val context = preferenceManager.context + preferenceManager.sharedPreferencesName = SETTINGS_NAME + preferenceManager.sharedPreferencesMode = Context.MODE_PRIVATE + val preferences = preferenceManager.sharedPreferences ?: return + + val screen = preferenceManager.createPreferenceScreen(context) + preferenceScreen = screen + + PreferenceCategory(context).apply { + title = getString(R.string.colors) + key = "colors" + isIconSpaceReserved = false + layoutResource = R.layout.preference_category + screen.addPreference(this) + + MaterialListPreference(context).apply { + key = THEME_KEY + title = getString(R.string.theme) + summary = getString(R.string.theme_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + + entries = context.resources.getStringArray(R.array.themes) + entryValues = arrayOf("light", "dark", "system") + value = preferences.getString(THEME_KEY, "system") + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = CUSTOM_THEME_KEY + title = getString(R.string.custom_theme_color) + summary = getString(R.string.custom_theme_color_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, it -> + screen.findPreference(COLOR_KEY)?.isEnabled = it as Boolean + true + } + addPreference(this) + } + + ColorListPreference(this@LookPreference).apply { + key = COLOR_KEY + setDefaultValue(context.defaultColor()) + isEnabled = preferences.getBoolean(CUSTOM_THEME_KEY, true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = AMOLED_KEY + title = getString(R.string.amoled) + summary = getString(R.string.amoled_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = NAVBAR_GRADIENT + title = getString(R.string.navbar_gradient) + summary = getString(R.string.navbar_gradient_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = BACKGROUND_GRADIENT + title = getString(R.string.background_gradient) + summary = getString(R.string.background_gradient_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = DYNAMIC_PLAYER + title = getString(R.string.dynamic_player) + summary = getString(R.string.dynamic_player_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + } + + PreferenceCategory(context).apply { + title = getString(R.string.ui) + key = "ui" + isIconSpaceReserved = false + layoutResource = R.layout.preference_category + screen.addPreference(this) + + SwitchPreferenceCompat(context).apply { + key = BIG_COVER + title = getString(R.string.big_cover) + summary = getString(R.string.big_cover_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = SCROLL_BAR + title = getString(R.string.scroll_bar) + summary = getString(R.string.scroll_bar_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = SHOW_BACKGROUND + title = getString(R.string.show_background) + summary = getString(R.string.show_background_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + } + + PreferenceCategory(context).apply { + title = getString(R.string.animation) + key = "animation" + isIconSpaceReserved = false + layoutResource = R.layout.preference_category + screen.addPreference(this) + + SwitchPreferenceCompat(context).apply { + key = BACK_ANIM + title = getString(R.string.back_animations) + summary = getString(R.string.back_animations_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = ANIMATIONS_KEY + title = getString(R.string.animations) + summary = getString(R.string.animations_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = SCROLL_ANIMATIONS_KEY + title = getString(R.string.scroll_animations) + summary = getString(R.string.scroll_animations_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + } + } + + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + THEME_KEY, CUSTOM_THEME_KEY, COLOR_KEY, AMOLED_KEY, + BIG_COVER, NAVBAR_GRADIENT, BACKGROUND_GRADIENT, + -> { + requireActivity().recreate() + } + + BACK_ANIM -> { + val pref = preferenceScreen.findPreference(key) + val enabled = pref?.isChecked == true + val backActivity = MainActivity.Back::class.java.name + val mainActivity = MainActivity::class.java.name + requireActivity().changeEnabledComponent( + if (enabled) backActivity else mainActivity, + if (enabled) mainActivity else backActivity + ) + } + } + } + + override fun onResume() { + super.onResume() + preferenceManager.sharedPreferences!! + .registerOnSharedPreferenceChangeListener(listener) + } + + override fun onPause() { + super.onPause() + preferenceManager.sharedPreferences!! + .unregisterOnSharedPreferenceChangeListener(listener) + } + } + + companion object { + + fun Activity.changeEnabledComponent(enabled: String, disabled: String) { + packageManager.setComponentEnabledSetting( + ComponentName(this, enabled), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + packageManager.setComponentEnabledSetting( + ComponentName(this, disabled), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsOtherFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsOtherFragment.kt new file mode 100644 index 0000000..e3f3158 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsOtherFragment.kt @@ -0,0 +1,88 @@ +package dev.brahmkshatriya.echo.ui.settings + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.preference.PreferenceFragmentCompat +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceImageHolder +import dev.brahmkshatriya.echo.ui.extensions.ExtensionsViewModel +import dev.brahmkshatriya.echo.utils.ContextUtils.SETTINGS_NAME +import dev.brahmkshatriya.echo.utils.PermsUtils.registerActivityResultLauncher +import dev.brahmkshatriya.echo.utils.exportSettings +import dev.brahmkshatriya.echo.utils.importSettings +import dev.brahmkshatriya.echo.utils.ui.prefs.SwitchLongClickPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.TransitionPreference +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class SettingsOtherFragment : BaseSettingsFragment() { + override val title get() = getString(R.string.other_settings) + override val icon get() = R.drawable.ic_more_horiz.toResourceImageHolder() + override val creator = { OtherPreference() } + + class OtherPreference : PreferenceFragmentCompat() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + configure() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val context = preferenceManager.context + preferenceManager.sharedPreferencesName = SETTINGS_NAME + preferenceManager.sharedPreferencesMode = Context.MODE_PRIVATE + val screen = preferenceManager.createPreferenceScreen(context) + preferenceScreen = screen + + SwitchLongClickPreference(context).apply { + title = getString(R.string.check_for_updates) + summary = getString(R.string.check_for_updates_summary) + key = "check_for_updates" + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + screen.addPreference(this) + setOnLongClickListener { + val viewModel by activityViewModel() + viewModel.update(requireActivity(), true) + } + } + + TransitionPreference(context).apply { + key = "export" + title = getString(R.string.export_settings) + summary = getString(R.string.export_settings_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + screen.addPreference(this) + setOnPreferenceClickListener { + val contract = ActivityResultContracts.CreateDocument("application/json") + requireActivity().registerActivityResultLauncher(contract) { uri -> + uri?.let { context.exportSettings(it) } + }.launch("echo-settings.json") + true + } + } + + TransitionPreference(context).apply { + key = "import" + title = getString(R.string.import_settings) + summary = getString(R.string.import_settings_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + screen.addPreference(this) + setOnPreferenceClickListener { + val contract = ActivityResultContracts.OpenDocument() + requireActivity().registerActivityResultLauncher(contract) { + it?.let { + context.importSettings(it) + requireActivity().recreate() + } + }.launch(arrayOf("application/json")) + true + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsPlayerFragment.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsPlayerFragment.kt new file mode 100644 index 0000000..b4324e6 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/ui/settings/SettingsPlayerFragment.kt @@ -0,0 +1,170 @@ +package dev.brahmkshatriya.echo.ui.settings + + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toResourceImageHolder +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.CACHE_SIZE +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.CLOSE_PLAYER +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.MORE_BRAIN_CAPACITY +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.SKIP_SILENCE +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.STREAM_QUALITY +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.UNMETERED_STREAM_QUALITY +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.streamQualities +import dev.brahmkshatriya.echo.playback.listener.PlayerRadio.Companion.AUTO_START_RADIO +import dev.brahmkshatriya.echo.ui.common.FragmentUtils.openFragment +import dev.brahmkshatriya.echo.ui.player.PlayerViewModel.Companion.KEEP_QUEUE +import dev.brahmkshatriya.echo.ui.settings.AudioEffectsFragment.Companion.AUDIO_FX +import dev.brahmkshatriya.echo.utils.ContextUtils.SETTINGS_NAME +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialListPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.MaterialSliderPreference +import dev.brahmkshatriya.echo.utils.ui.prefs.TransitionPreference + +class SettingsPlayerFragment : BaseSettingsFragment() { + override val title get() = getString(R.string.player) + override val icon get() = R.drawable.ic_play_circle.toResourceImageHolder() + override val creator = { AudioPreference() } + + class AudioPreference : PreferenceFragmentCompat() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + configure() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + val context = preferenceManager.context + preferenceManager.sharedPreferencesName = SETTINGS_NAME + preferenceManager.sharedPreferencesMode = Context.MODE_PRIVATE + val screen = preferenceManager.createPreferenceScreen(context) + preferenceScreen = screen + + PreferenceCategory(context).apply { + title = getString(R.string.playback) + key = "playback" + isIconSpaceReserved = false + layoutResource = R.layout.preference_category + screen.addPreference(this) + + TransitionPreference(context).apply { + key = AUDIO_FX + title = getString(R.string.audio_fx) + summary = getString(R.string.audio_fx_summary) + layoutResource = R.layout.preference + isIconSpaceReserved = false + addPreference(this) + } + + MaterialListPreference(context).apply { + key = STREAM_QUALITY + title = getString(R.string.stream_quality) + summary = getString(R.string.stream_quality_summary) + entries = context.resources.getStringArray(R.array.stream_qualities) + entryValues = streamQualities + layoutResource = R.layout.preference + isIconSpaceReserved = false + setDefaultValue(streamQualities[1]) + addPreference(this) + } + + MaterialListPreference(context).apply { + key = UNMETERED_STREAM_QUALITY + title = getString(R.string.unmetered_stream_quality) + summary = getString(R.string.unmetered_stream_quality_summary) + entries = + context.resources.getStringArray(R.array.stream_qualities) + getString(R.string.off) + entryValues = streamQualities + "off" + layoutResource = R.layout.preference + isIconSpaceReserved = false + setDefaultValue("off") + addPreference(this) + } + } + + PreferenceCategory(context).apply { + title = getString(R.string.behavior) + key = "behavior" + isIconSpaceReserved = false + layoutResource = R.layout.preference_category + screen.addPreference(this) + + SwitchPreferenceCompat(context).apply { + key = KEEP_QUEUE + title = getString(R.string.keep_queue) + summary = getString(R.string.keep_queue_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = CLOSE_PLAYER + title = getString(R.string.stop_player) + summary = getString(R.string.stop_player_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = SKIP_SILENCE + title = getString(R.string.skip_silence) + summary = getString(R.string.skip_silence_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = MORE_BRAIN_CAPACITY + title = getString(R.string.more_brain_capacity) + summary = getString(R.string.more_brain_capacity_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(false) + addPreference(this) + } + + SwitchPreferenceCompat(context).apply { + key = AUTO_START_RADIO + title = getString(R.string.auto_start_radio) + summary = getString(R.string.auto_start_radio_summary) + layoutResource = R.layout.preference_switch + isIconSpaceReserved = false + setDefaultValue(true) + addPreference(this) + } + + MaterialSliderPreference(context, 200, 1000, allowOverride = true).apply { + key = CACHE_SIZE + title = getString(R.string.cache_size) + summary = getString(R.string.cache_size_summary) + isIconSpaceReserved = false + setDefaultValue(250) + addPreference(this) + } + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + val view = listView.findViewById(preference.key.hashCode()) + return when (preference.key) { + AUDIO_FX -> { + requireActivity().openFragment(view) + true + } + + else -> false + } + } + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppShortcuts.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppShortcuts.kt new file mode 100644 index 0000000..3dfec4d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppShortcuts.kt @@ -0,0 +1,103 @@ +package dev.brahmkshatriya.echo.utils + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.MusicExtension +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.utils.image.ImageUtils.loadAsCircleDrawable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +object AppShortcuts { + private suspend fun Context.applyAppShortcuts(extensions: List) { + val max = ShortcutManagerCompat.getMaxShortcutCountPerActivity(this) + val ext = extensions.take(max) + val shortcuts = ext.map { extension -> + val bitmap = + extension.metadata.icon.loadAsCircleDrawable(this) + ?.toBitmap()?.addPadding() + ?: ResourcesCompat.getDrawable(resources, R.drawable.ic_extension, theme)!! + .toBitmap() + ShortcutInfoCompat.Builder(this, extension.id) + .setShortLabel(extension.name) + .setIcon(IconCompat.createWithBitmap(bitmap)) + .setIntent(Intent(Intent.ACTION_VIEW, "echo://music/${extension.id}".toUri())) + .build() + } + ShortcutManagerCompat.removeAllDynamicShortcuts(this) + ShortcutManagerCompat.addDynamicShortcuts(this, shortcuts) + ShortcutManagerCompat.disableShortcuts( + this, + ext.filter { !it.isEnabled }.map { it.id }, + getString(R.string.disabled) + ) + } + + private fun Bitmap.addPadding(): Bitmap { + val percentage = 0.05f + val max = maxOf(width, height) + val padding = (max * percentage).roundToInt().toFloat() + val diameter = (max + padding * 2).toInt() + val newBitmap = createBitmap(diameter, diameter, config ?: Bitmap.Config.ARGB_8888) + val canvas = Canvas(newBitmap) + newBitmap.applyCanvas { +// canvas.drawColor(getCommonColor()) + canvas.drawBitmap(this@addPadding, padding, padding, null) + } + return newBitmap + } + +// private fun Bitmap.circleCrop(): Bitmap { +// val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) +// +// val minSize = minOf(width, height) +// val radius = minSize / 2f +// val output = createBitmap(minSize, minSize, config ?: Bitmap.Config.ARGB_8888) +// output.applyCanvas { +// drawCircle(radius, radius, radius, paint) +// paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) +// drawBitmap(this@circleCrop, radius - width / 2f, radius - height / 2f, paint) +// } +// +// return output +// } + +// private fun Bitmap.getCommonColor(): Int { +// val pixels = IntArray(width * height) +// getPixels(pixels, 0, width, 0, 0, width, height) +// val percent = 32 +// val groupedColors = pixels +// .filter { (it shr 24 and 0xFF) > 64 } +// .groupBy { color -> +// val r = (color shr 16 and 0xFF) / percent * percent +// val g = (color shr 8 and 0xFF) / percent * percent +// val b = (color and 0xFF) / percent * percent +// (0xFF shl 24) or (r shl 16) or (g shl 8) or b +// } +// .mapKeys { entry -> entry.value.groupBy { it }.maxByOrNull { it.value.size }?.key } +// .mapValues { it.value.size } +// return groupedColors.maxByOrNull { it.value }?.key ?: 0 +// } + + fun configureAppShortcuts(loader: ExtensionLoader) { + val scope = loader.scope + val musicExt = loader.music + scope.launch { + musicExt.collectLatest { + loader.app.context.applyAppShortcuts(it) + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt new file mode 100644 index 0000000..a9176cd --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/AppUpdater.kt @@ -0,0 +1,194 @@ +package dev.brahmkshatriya.echo.utils + +import android.content.Context +import android.os.Build +import dev.brahmkshatriya.echo.BuildConfig +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.helpers.ContinuationCallback.Companion.await +import dev.brahmkshatriya.echo.common.models.Message +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.utils.ContextUtils.appVersion +import dev.brahmkshatriya.echo.utils.ContextUtils.getTempFile +import dev.brahmkshatriya.echo.utils.Serializer.toData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.use +import java.io.File +import java.util.zip.ZipFile + +object AppUpdater { + + private val client = OkHttpClient() + + @Suppress("KotlinConstantConditions") + suspend fun updateApp(app: App): File? { + val throwableFlow = app.throwFlow + val messageFlow = app.messageFlow + val githubRepo = app.context.getString(R.string.app_github_repo) + val appType = BuildConfig.BUILD_TYPE + val version = appVersion() + + val url = runCatching { + when (appType) { + "stable" -> { + val currentVersion = version.substringBefore('_') + val updateUrl = "https://api.github.com/repos/$githubRepo/releases" + getGithubUpdateUrl(currentVersion, updateUrl, client) ?: return null + } + + "nightly" -> { + val hash = version.substringBefore("(").substringAfter('_') + val id = getGithubWorkflowId(hash, githubRepo, client) ?: return null + "https://nightly.link/$githubRepo/actions/runs/$id/artifact.zip" + } + + else -> return null + } + }.getOrElse { + throwableFlow.emit(it) + return null + } + + messageFlow.emit( + Message( + app.context.run { + getString(R.string.downloading_update_for_x, getString(R.string.app_name)) + } + ) + ) + return runCatching { + val download = downloadUpdate(app.context, url, client).getOrThrow() + if (appType == "stable") download else unzipApk(download) + }.getOrElse { + throwableFlow.emit(it) + return null + } + } + + private val githubRegex = Regex("https://api\\.github\\.com/repos/([^/]*)/([^/]*)/") + suspend fun getGithubUpdateUrl( + currentVersion: String, + updateUrl: String, + client: OkHttpClient + ) = run { + val (user, repo) = githubRegex.find(updateUrl)?.destructured + ?: throw Exception("Invalid Github URL") + val url = "https://api.github.com/repos/$user/$repo/releases/latest" + val request = Request.Builder().url(url).build() + val res = runCatching { + client.newCall(request).await().use { + it.body.string().toData() + }.getOrThrow() + }.getOrElse { + throw Exception("Failed to fetch latest release", it) + } + if (res.tagName != currentVersion) { + res.assets.sortedByDescending { + it.name.contains(Build.SUPPORTED_ABIS.first()) + }.firstOrNull { + it.name.endsWith("apk") + }?.browserDownloadUrl ?: throw Exception("No EApk assets found") + } else { + null + } + } + + private suspend fun getGithubWorkflowId( + hash: String, + githubRepo: String, + client: OkHttpClient + ) = runCatching { + val url = + "https://api.github.com/repos/$githubRepo/actions/workflows/nightly.yml/runs?per_page=1&conclusion=success" + val request = Request.Builder().url(url).build() + client.newCall(request).await().body.string().toData().getOrThrow() + .workflowRuns.firstOrNull { it.sha.take(7) != hash }?.id + }.getOrElse { + throw Exception("Failed to fetch workflow ID", it) + } + + @Serializable + data class GithubReleaseResponse( + @SerialName("tag_name") + val tagName: String, + @SerialName("created_at") + val createdAt: String, + val assets: List + ) { + @Serializable + data class Asset( + val name: String, + @SerialName("browser_download_url") + val browserDownloadUrl: String + ) + } + + @Serializable + data class GithubRunsResponse( + @SerialName("workflow_runs") + val workflowRuns: List + ) { + @Serializable + data class Run( + val id: Long, + @SerialName("head_sha") + val sha: String, + ) + } + + suspend fun downloadUpdate( + context: Context, + url: String, + client: OkHttpClient + ) = runIOCatching { + val request = Request.Builder().url(url).build() + val res = client.newCall(request).await().body.byteStream() + val file = context.getTempFile() + res.use { input -> file.outputStream().use { output -> input.copyTo(output) } } + file + } + + private fun unzipApk(file: File): File { + val zipFile = ZipFile(file) + val apkFile = File.createTempFile("temp", ".apk", file.parentFile!!) + zipFile.use { zip -> + val apkEntry = zip.entries().asSequence().firstOrNull { + !it.isDirectory && it.name.endsWith(".apk") + } ?: throw Exception("No APK file found in the zip") + zip.getInputStream(apkEntry).use { input -> + apkFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + return apkFile + } + + suspend fun getUpdateFileUrl( + currentVersion: String, + updateUrl: String, + client: OkHttpClient + ) = runIOCatching { + if (updateUrl.isEmpty()) return@runIOCatching null + if (updateUrl.startsWith("https://api.github.com/repos/")) { + getGithubUpdateUrl(currentVersion, updateUrl, client) + } else { + throw Exception("Unsupported update url") + } + } + + private suspend fun runIOCatching( + block: suspend () -> T + ) = withContext(Dispatchers.IO) { + runCatching { runCatching { block() }.getOrElse { throw UpdateException(it) } } + } + + class UpdateException(override val cause: Throwable) : Exception(cause) { + override val message: String + get() = "Update failed: ${cause.message}" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CacheUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CacheUtils.kt new file mode 100644 index 0000000..d0f54d5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CacheUtils.kt @@ -0,0 +1,42 @@ +package dev.brahmkshatriya.echo.utils + +import android.content.Context +import dev.brahmkshatriya.echo.utils.Serializer.toData +import dev.brahmkshatriya.echo.utils.Serializer.toJson +import java.io.File + +object CacheUtils { + + fun cacheDir(context: Context, folderName: String) = + File(context.cacheDir, "context/$folderName").apply { mkdirs() } + + const val CACHE_FOLDER_SIZE = 50 * 1024 * 1024 //50MB + + inline fun Context.saveToCache( + id: String, data: T?, folderName: String = T::class.java.simpleName + ) = runCatching { + val fileName = id.hashCode().toString() + val cacheDir = cacheDir(this, folderName) + val file = File(cacheDir, fileName) + + var size = cacheDir.walk().sumOf { it.length().toInt() } + while (size > CACHE_FOLDER_SIZE) { + val files = cacheDir.listFiles() + files?.sortBy { it.lastModified() } + files?.firstOrNull()?.delete() + size = cacheDir.walk().sumOf { it.length().toInt() } + } + file.writeText(data.toJson()) + } + + inline fun Context.getFromCache( + id: String, folderName: String = T::class.java.simpleName + ): T? { + val fileName = id.hashCode().toString() + val cacheDir = cacheDir(this, folderName) + val file = File(cacheDir, fileName) + return if (file.exists()) runCatching { + file.readText().toData().getOrThrow() + }.getOrNull() else null + } +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ContextUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ContextUtils.kt new file mode 100644 index 0000000..d094f60 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ContextUtils.kt @@ -0,0 +1,69 @@ +package dev.brahmkshatriya.echo.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build.SUPPORTED_ABIS +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.google.common.util.concurrent.ListenableFuture +import dev.brahmkshatriya.echo.BuildConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.File + +object ContextUtils { + fun appVersion() = BuildConfig.VERSION_NAME + " " + BuildConfig.BUILD_TYPE + fun getArch(): String { + SUPPORTED_ABIS.firstOrNull()?.let { return it } + return System.getProperty("os.arch") + ?: System.getProperty("os.product.cpu.abi") + ?: "Unknown" + } + + fun Context.copyToClipboard(label: String?, string: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, string) + clipboard.setPrimaryClip(clip) + } + + fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) = + lifecycleScope.launch { + flow.flowWithLifecycle(lifecycle).collectLatest(block) + } + + fun LifecycleOwner.collect(flow: Flow, block: suspend (T) -> Unit) = + lifecycleScope.launch { + flow.collect { + runCatching { block(it) } + } + } + + fun Context.listenFuture(future: ListenableFuture, block: (Result) -> Unit) { + future.addListener({ + val result = runCatching { future.get() } + block(result) + }, ContextCompat.getMainExecutor(this)) + } + + fun LifecycleOwner.emit(flow: MutableSharedFlow, value: T) { + lifecycleScope.launch { + flow.emit(value) + } + } + + const val SETTINGS_NAME = "settings" + fun Context.getSettings() = getSharedPreferences(SETTINGS_NAME, Context.MODE_PRIVATE)!! + + private fun Context.getTempDir() = cacheDir.resolve("apks").apply { mkdirs() } + fun Context.getTempFile(ext: String = "apk"): File = + File.createTempFile("temp", ".$ext", getTempDir()) + + fun Context.cleanupTempApks() { + getTempDir().deleteRecursively() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CoroutineUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CoroutineUtils.kt new file mode 100644 index 0000000..77dd664 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/CoroutineUtils.kt @@ -0,0 +1,96 @@ +package dev.brahmkshatriya.echo.utils + +import android.content.Context +import androidx.core.content.ContextCompat +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext +import kotlin.experimental.ExperimentalTypeInference + +object CoroutineUtils { + fun setDebug() { + System.setProperty( + kotlinx.coroutines.DEBUG_PROPERTY_NAME, + kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON + ) + } + + suspend fun Flow.collectWith(flow: Flow, block: suspend (T, R) -> Unit) { + this.combine(flow) { t, r -> t to r }.collectLatest { (t, r) -> block(t, r) } + } + + fun Flow.throttleLatest(delayMillis: Long): Flow = conflate().transform { + emit(it) + delay(delayMillis) + } + + @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTypeInference::class) + inline fun combineTransformLatest( + vararg flows: Flow, + @BuilderInference noinline transform: suspend FlowCollector.(Array) -> Unit + ): Flow { + return combine(*flows) { it } + .transformLatest(transform) + } + + @OptIn(ExperimentalTypeInference::class) + fun Flow.combineTransformLatest( + flow2: Flow, + @BuilderInference transform: suspend FlowCollector.(T1, T2) -> Unit + ): Flow { + return combineTransformLatest(this, flow2) { args -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2 + ) + } + } + + fun CoroutineScope.future( + context: CoroutineContext = Dispatchers.IO, block: suspend () -> T + ): ListenableFuture { + val future = SettableFuture.create() + launch(context) { + future.set(block()) + } + return future + } + + fun CoroutineScope.futureCatching( + context: CoroutineContext = Dispatchers.IO, block: suspend () -> T + ): ListenableFuture { + val future = SettableFuture.create() + launch(context) { + runCatching { + future.set(block()) + }.getOrElse { + future.setException(it) + } + } + return future + } + + + suspend fun ListenableFuture.await(context: Context) = suspendCancellableCoroutine { + it.invokeOnCancellation { + cancel(true) + } + addListener({ + it.resumeWith(runCatching { get()!! }) + }, ContextCompat.getMainExecutor(context)) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PauseTimer.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PauseTimer.kt new file mode 100644 index 0000000..87ddd04 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PauseTimer.kt @@ -0,0 +1,39 @@ +package dev.brahmkshatriya.echo.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class PauseTimer( + private val scope: CoroutineScope, + private var millisInFuture: Long, + private val onTimerFinish: () -> Unit, +) { + private var job: Job? = null + private var lastPauseTime: Long = System.currentTimeMillis() + private var isTimerPaused: Boolean = true + + fun resume() { + if (isTimerPaused) { + val diff = System.currentTimeMillis() - lastPauseTime + val remainingTime = millisInFuture - diff + if (remainingTime < 0) return + job = scope.launch { + delay(remainingTime) + onTimerFinish() + } + isTimerPaused = false + } + } + + fun pause() { + job?.cancel() + lastPauseTime = System.currentTimeMillis() + isTimerPaused = true + } + + init { + resume() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PermsUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PermsUtils.kt new file mode 100644 index 0000000..350182d --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PermsUtils.kt @@ -0,0 +1,128 @@ +package dev.brahmkshatriya.echo.utils + +import android.Manifest +import android.Manifest.permission.READ_MEDIA_AUDIO +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R +import kotlinx.coroutines.launch +import java.util.UUID + +object PermsUtils { + + fun ComponentActivity.checkAppPermissions(onGranted: suspend () -> Unit) { + val perms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) listOfNotNull( + Triple( + Manifest.permission.POST_NOTIFICATIONS, + getString(R.string.notifications), + getString(R.string.notifications_permission_summary) + ), + Triple( + READ_MEDIA_AUDIO, + getString(R.string.audio), + getString(R.string.audio_permission_summary) + ), +// Triple( +// Manifest.permission.READ_MEDIA_VIDEO, +// getString(R.string.video), +// getString(R.string.video_permission_summary) +// ), +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) Triple( +// Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, +// getString(R.string.video), +// getString(R.string.video_permission_summary) +// ) else null + ) else listOfNotNull( + Triple( + Manifest.permission.READ_EXTERNAL_STORAGE, + getString(R.string.read_storage), + getString(R.string.audio_permission_summary) + ), + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) Triple( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + getString(R.string.write_storage), + getString(R.string.write_storage_permission_summary) + ) else null + ) + getPermissionsLauncher(perms, onGranted = { + if (it == READ_MEDIA_AUDIO) lifecycleScope.launch { + onGranted() + } + }) + } + + private fun ComponentActivity.getPermissionsLauncher( + perms: List>, + onCancel: (String) -> Unit = {}, + onGranted: (String) -> Unit = {}, + onRequest: (String) -> Unit = { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + startActivity(this) + } + } + ) = run { + val contract = ActivityResultContracts.RequestMultiplePermissions() + val notGranted = perms.filterNot { + ContextCompat.checkSelfPermission(this, it.first) == PackageManager.PERMISSION_GRANTED + } + val launcher = if (notGranted.isNotEmpty()) + registerActivityResultLauncher(contract) { result -> + val map = perms.associateBy { it.first } + result.forEach { (p, granted) -> + val perm = map[p]!! + if (granted) onGranted(p) + else MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.x_permission_required, perm.second)) + .setMessage(perm.third) + .setPositiveButton(getString(R.string.okay)) { _, _ -> onRequest(p) } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + Toast.makeText( + this, + getString(R.string.x_was_not_granted, perm.second), + Toast.LENGTH_SHORT + ).show() + onCancel(p) + }.show() + } + } + else { + perms.forEach { onGranted(it.first) } + null + } + launcher?.launch(perms.map { it.first }.toTypedArray()) + } + + fun ComponentActivity.registerActivityResultLauncher( + contract: ActivityResultContract, + block: (O) -> Unit + ): ActivityResultLauncher { + val key = UUID.randomUUID().toString() + var launcher: ActivityResultLauncher? = null + val callback = ActivityResultCallback { + block.invoke(it) + launcher?.unregister() + } + + lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver { + override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) { + launcher?.unregister() + } + }) + + launcher = activityResultRegistry.register(key, contract, callback) + return launcher + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PrefsUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PrefsUtils.kt new file mode 100644 index 0000000..e6f9e6e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/PrefsUtils.kt @@ -0,0 +1,144 @@ +package dev.brahmkshatriya.echo.utils + + +import android.content.Context +import android.net.Uri +import androidx.core.content.edit +import dev.brahmkshatriya.echo.extensions.ExtensionUtils.extensionPrefId +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.CUSTOM_EFFECTS +import dev.brahmkshatriya.echo.playback.listener.EffectsListener.Companion.GLOBAL_FX +import dev.brahmkshatriya.echo.utils.ContextUtils.SETTINGS_NAME +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import java.io.BufferedReader +import java.io.InputStreamReader + + +private fun Map.toJsonElementMap(): Map { + return this.mapValues { (_, value) -> + when (value) { + is Boolean -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is Set<*> -> JsonArray(value.filterIsInstance().map { JsonPrimitive(it) }) + null -> JsonNull + else -> throw IllegalArgumentException("Unsupported type for serialization: ${value::class.java}") + } + } +} + + +fun Context.exportSettings(uri: Uri) { + val settingsPrefs = getSharedPreferences(SETTINGS_NAME, Context.MODE_PRIVATE) + val globalFxPrefs = getSharedPreferences(GLOBAL_FX, Context.MODE_PRIVATE) + + val settingsJson = settingsPrefs.all.toJsonElementMap() + val globalFxJson = globalFxPrefs.all.toJsonElementMap() + val customFxJson = globalFxPrefs.getStringSet(CUSTOM_EFFECTS, emptySet())?.map { fxName -> + fxName to getSharedPreferences("fx_$fxName", Context.MODE_PRIVATE).all.toJsonElementMap() + } + + val allPrefsJson = mutableMapOf(SETTINGS_NAME to settingsJson, GLOBAL_FX to globalFxJson) + customFxJson?.forEach { (name, map) -> allPrefsJson["fx_$name"] = map } + + contentResolver.openOutputStream(uri, "w")?.use { out -> + out.write(Json.encodeToString(allPrefsJson).toByteArray()) + } +} + + +fun Context.importSettings(uri: Uri) { + val jsonString = contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).readText() + } ?: return + + val allPrefsJson = Json.decodeFromString>(jsonString) + + allPrefsJson.forEach { (prefName, prefMap) -> + getSharedPreferences(prefName, Context.MODE_PRIVATE).edit { + prefMap.forEach { (key, value) -> + when (value) { + is JsonPrimitive if value.booleanOrNull != null -> putBoolean( + key, + value.booleanOrNull!! + ) + + is JsonPrimitive if value.intOrNull != null -> putInt(key, value.intOrNull!!) + is JsonPrimitive if value.longOrNull != null -> putLong(key, value.longOrNull!!) + is JsonPrimitive if value.floatOrNull != null -> putFloat( + key, + value.floatOrNull!! + ) + + is JsonPrimitive if value.isString -> putString(key, value.content) + is JsonArray -> putStringSet( + key, + value.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }.toSet() + ) + + is JsonNull -> remove(key) + else -> throw IllegalArgumentException("Unsupported type for deserialization: ${value::class.java}") + } + } + } + } +} + + +fun Context.exportExtensionSettings(extensionType: String, extensionId: String, uri: Uri) { + val prefName = extensionPrefId(extensionType, extensionId) + val settingsPrefs = getSharedPreferences(prefName, Context.MODE_PRIVATE) + val settingsJson = settingsPrefs.all.toJsonElementMap() + + contentResolver.openOutputStream(uri, "w")?.use { out -> + out.write(Json.encodeToString(settingsJson).toByteArray()) + } +} + + +fun Context.importExtensionSettings(extensionType: String, extensionId: String, uri: Uri) { + val jsonString = contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).readText() + } ?: return + + val settingsJson = Json.decodeFromString>(jsonString) + val prefName = extensionPrefId(extensionType, extensionId) + + getSharedPreferences(prefName, Context.MODE_PRIVATE).edit { + settingsJson.forEach { (key, value) -> + when (value) { + is JsonPrimitive if value.booleanOrNull != null -> putBoolean( + key, + value.booleanOrNull!! + ) + + is JsonPrimitive if value.intOrNull != null -> putInt(key, value.intOrNull!!) + is JsonPrimitive if value.longOrNull != null -> putLong(key, value.longOrNull!!) + is JsonPrimitive if value.floatOrNull != null -> putFloat( + key, + value.floatOrNull!! + ) + + is JsonPrimitive if value.isString -> putString(key, value.content) + is JsonArray -> putStringSet( + key, + value.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }.toSet() + ) + + is JsonNull -> remove(key) + else -> throw IllegalArgumentException("Unsupported type for deserialization: ${value::class.java}") + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/Serializer.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/Serializer.kt new file mode 100644 index 0000000..bf25e30 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/Serializer.kt @@ -0,0 +1,39 @@ +package dev.brahmkshatriya.echo.utils + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import kotlinx.serialization.json.Json + +object Serializer { + val json = Json { + ignoreUnknownKeys = true + } + + inline fun String.toData() = runCatching { + json.decodeFromString(this) + }.recoverCatching { + throw DecodingException(this, it) + } + + inline fun T.toJson() = json.encodeToString(this) + + inline fun Bundle.putSerialized(key: String, value: T) { + putString(key, value.toJson()) + } + + inline fun Bundle.getSerialized(key: String): Result? { + return getString(key)?.toData() + } + + @Suppress("DEPRECATION") + inline fun Bundle.getParcel(key: String?) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getParcelable(key, T::class.java) + else getParcelable(key) + + val Throwable.rootCause: Throwable + get() = generateSequence(this) { it.cause }.last() + + data class DecodingException(val json: String, val error: Throwable) : Exception(error) +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ShaUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ShaUtils.kt new file mode 100644 index 0000000..9f96d7a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ShaUtils.kt @@ -0,0 +1,19 @@ +package dev.brahmkshatriya.echo.utils + +import java.io.File +import java.security.MessageDigest + +object ShaUtils { + + fun getSha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { inputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/BlurTransformation.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/BlurTransformation.kt new file mode 100644 index 0000000..8835b9c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/BlurTransformation.kt @@ -0,0 +1,74 @@ +@file:Suppress("DEPRECATION") + +package dev.brahmkshatriya.echo.utils.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import coil3.size.Size +import coil3.transform.Transformation + +class BlurTransformation( + private val context: Context, + private val radius: Float = DEFAULT_RADIUS +) : Transformation() { + + override val cacheKey = "${BlurTransformation::class.simpleName}-$radius" + + override suspend fun transform(input: Bitmap, size: Size) = + input.blurred(context, radius) + + companion object { + private const val DEFAULT_RADIUS = 20f + private const val MAX_WIDTH = 256 + fun Bitmap.blurred( + context: Context, + radius: Float = DEFAULT_RADIUS + ): Bitmap { + require(radius in 0.0..25.0) { "radius must be in [0, 25]." } + + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + val scale = width.toFloat() / MAX_WIDTH + val scaledHeight = (height / scale).toInt() + val output = + createBitmap(MAX_WIDTH, scaledHeight, config ?: Bitmap.Config.ARGB_8888) + output.applyCanvas { + scale(1 / scale, 1 / scale) + drawBitmap(this@blurred, 0f, 0f, paint) + } + + var script: RenderScript? = null + var tmpInt: Allocation? = null + var tmpOut: Allocation? = null + var blur: ScriptIntrinsicBlur? = null + try { + script = RenderScript.create(context) + tmpInt = Allocation.createFromBitmap( + script, + output, + Allocation.MipmapControl.MIPMAP_NONE, + Allocation.USAGE_SCRIPT + ) + tmpOut = Allocation.createTyped(script, tmpInt.type) + blur = ScriptIntrinsicBlur.create(script, Element.U8_4(script)) + blur.setRadius(radius) + blur.setInput(tmpInt) + blur.forEach(tmpOut) + tmpOut.copyTo(output) + } finally { + script?.destroy() + tmpInt?.destroy() + tmpOut?.destroy() + blur?.destroy() + } + + return output + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/ImageUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/ImageUtils.kt new file mode 100644 index 0000000..f01a71a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/ImageUtils.kt @@ -0,0 +1,162 @@ +package dev.brahmkshatriya.echo.utils.image + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import androidx.core.graphics.drawable.toDrawable +import androidx.core.graphics.toColorInt +import coil3.Image +import coil3.asDrawable +import coil3.imageLoader +import coil3.load +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.error +import coil3.request.placeholder +import coil3.request.target +import coil3.request.transformations +import coil3.transform.CircleCropTransformation +import coil3.transform.Transformation +import dev.brahmkshatriya.echo.common.models.ImageHolder + +object ImageUtils { + + private fun tryWith(print: Boolean = false, block: () -> T): T? { + return try { + block() + } catch (e: Throwable) { + if (print) e.printStackTrace() + null + } + } + + private suspend fun tryWithSuspend(print: Boolean = true, block: suspend () -> T): T? { + return try { + block() + } catch (e: Throwable) { + if (print) e.printStackTrace() + null + } + } + + private fun View.enqueue(builder: ImageRequest.Builder) = + context.imageLoader.enqueue(builder.build()) + + fun ImageHolder?.loadInto( + imageView: ImageView, placeholder: Int? = null, errorDrawable: Int? = null + ) = tryWith { + val request = createRequest(imageView.context, placeholder, errorDrawable) + request.target(imageView) + imageView.enqueue(request) + } + + fun ImageHolder.getCachedDrawable(context: Context): Drawable? { + val key = diskId ?: return null + return context.imageLoader.diskCache?.openSnapshot(key)?.use { + Drawable.createFromPath(it.data.toFile().absolutePath) + } + } + + fun ImageHolder?.loadWithThumb( + view: T, thumbnail: Drawable? = null, + error: Int? = null, onDrawable: T.(Drawable?) -> Unit + ) = tryWith(true) { + tryWith(false) { onDrawable(view, thumbnail) } + val request = createRequest(view.context, null, error) + fun setDrawable(image: Image?) { + val drawable = image?.asDrawable(view.resources) + tryWith(false) { onDrawable(view, drawable) } + } + request.target({}, ::setDrawable, ::setDrawable) + view.enqueue(request) + } + + private val circleCrop = CircleCropTransformation() + private val squareCrop = SquareCropTransformation() + fun ImageHolder?.loadAsCircle( + view: T, + placeholder: Int? = null, + error: Int? = null, + onDrawable: (Drawable?) -> Unit + ) = tryWith { + val request = createRequest(view.context, placeholder, error, circleCrop) + fun setDrawable(image: Image?) { + val drawable = image?.asDrawable(view.resources) + tryWith(false) { onDrawable(drawable) } + } + request.target(::setDrawable, ::setDrawable, ::setDrawable) + view.enqueue(request) + } + + suspend fun ImageHolder?.loadDrawable( + context: Context + ) = tryWithSuspend { + val request = createRequest(context, null, null) + context.imageLoader.execute(request.build()).image?.asDrawable(context.resources) + } + + suspend fun ImageHolder?.loadAsCircleDrawable( + context: Context + ) = tryWithSuspend { + val request = createRequest(context, null, null, circleCrop) + context.imageLoader.execute(request.build()).image?.asDrawable(context.resources) + } + + fun ImageView.loadBlurred(drawable: Drawable?, radius: Float) = tryWith { + if (drawable == null) setImageDrawable(null) + load(drawable) { + transformations(BlurTransformation(context, radius)) + } + } + + private val ImageHolder.diskId + get() = when (this) { + is ImageHolder.NetworkRequestImageHolder -> request.toString().hashCode().toString() + else -> null + } + + private fun createRequest( + imageHolder: ImageHolder, + builder: ImageRequest.Builder, + ) = imageHolder.run { + builder.diskCacheKey(diskId) + when (this) { + is ImageHolder.ResourceUriImageHolder -> builder.data(uri) + is ImageHolder.NetworkRequestImageHolder -> { + val headerBuilder = NetworkHeaders.Builder() + request.headers.forEach { (key, value) -> + headerBuilder[key] = value + } + builder.httpHeaders(headerBuilder.build()) + builder.data(request.url) + } + + is ImageHolder.ResourceIdImageHolder -> builder.data(resId) + is ImageHolder.HexColorImageHolder -> builder.data(hex.toColorInt().toDrawable()) + } + } + + private fun ImageHolder?.createRequest( + context: Context, + placeholder: Int?, + errorDrawable: Int?, + vararg transformations: Transformation + ): ImageRequest.Builder { + val builder = ImageRequest.Builder(context) + var error = errorDrawable + if (error == null) error = placeholder + + if (this == null) { + if (error != null) builder.data(error) + return builder + } + createRequest(this, builder) + placeholder?.let { builder.placeholder(it) } + error?.let { builder.error(it) } + val list = if (crop) listOf(squareCrop, *transformations) else transformations.toList() + if (list.isNotEmpty()) builder.transformations(list) + return builder + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/SquareCropTransformation.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/SquareCropTransformation.kt new file mode 100644 index 0000000..c6d3b2e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/image/SquareCropTransformation.kt @@ -0,0 +1,16 @@ +package dev.brahmkshatriya.echo.utils.image + +import android.graphics.Bitmap +import coil3.size.Size +import coil3.transform.Transformation + +class SquareCropTransformation : Transformation() { + val version = 1 + override val cacheKey = "${javaClass.simpleName}.$version" + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + val max = input.width.coerceAtMost(input.height) + val x = (input.width - max) / 2 + val y = (input.height - max) / 2 + return Bitmap.createBitmap(input, x, y, max, max) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AnimationUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AnimationUtils.kt new file mode 100644 index 0000000..733b3af --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AnimationUtils.kt @@ -0,0 +1,252 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationSet +import android.view.animation.Interpolator +import android.view.animation.RotateAnimation +import android.view.animation.ScaleAnimation +import android.view.animation.TranslateAnimation +import androidx.core.animation.doOnEnd +import androidx.core.view.doOnLayout +import androidx.core.view.doOnPreDraw +import androidx.core.view.forEach +import androidx.core.view.forEachIndexed +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import com.google.android.material.color.MaterialColors +import com.google.android.material.motion.MotionUtils +import com.google.android.material.navigation.NavigationBarView +import com.google.android.material.transition.MaterialSharedAxis +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.utils.ContextUtils.SETTINGS_NAME +import kotlin.math.absoluteValue +import kotlin.math.sign + +object AnimationUtils { + + private fun startAnimation( + view: View, animation: ViewPropertyAnimator, durationMultiplier: Float = 1f + ) = view.run { + clearAnimation() + val interpolator = getInterpolator(context) + val duration = animationDuration * durationMultiplier + animation.setInterpolator(interpolator).setDuration(duration.toLong()).start() + } + + private fun getInterpolator(context: Context) = MotionUtils.resolveThemeInterpolator( + context, com.google.android.material.R.attr.motionEasingStandardInterpolator, + FastOutSlowInInterpolator() + ) + + fun View.animateMarginTop(hide: Boolean, onEnd: (() -> Unit)? = null) { + if (!animations) { + isVisible = hide + return + } + isVisible = true + val fromMargin = (layoutParams as ViewGroup.MarginLayoutParams).topMargin + val toMargin = if (hide) 0 else -height + val fromAlpha = alpha + val toAlpha = if (hide) 1f else 0f + + val animator = ValueAnimator.ofFloat(0f, 1f) + animator.addUpdateListener { valueAnimator -> + val fraction = valueAnimator.animatedFraction + val params = layoutParams as ViewGroup.MarginLayoutParams + params.topMargin = (fromMargin + (toMargin - fromMargin) * fraction).toInt() + layoutParams = params + alpha = fromAlpha + (toAlpha - fromAlpha) * fraction + } + animator.duration = animationDuration + animator.interpolator = getInterpolator(context) + animator.doOnEnd { + isVisible = hide + onEnd?.invoke() + } + animator.start() + } + + fun NavigationBarView.animateTranslation( + isRail: Boolean, + isMainFragment: Boolean, + isPlayerCollapsed: Boolean, + animate: Boolean = true, + action: (Float) -> Unit + ) = doOnLayout { + val visible = isMainFragment && isPlayerCollapsed + val value = if (visible) 0f + else if (isRail) -width.toFloat() else height.toFloat() + if (animations && animate) { + var animation = if (isRail) animate().translationX(value) + else animate().translationY(value) + animation = if (visible) animation.withStartAction { action(value) } + else animation.withEndAction { action(value) } + startAnimation(this, animation) + + val delay = if (!visible) 0L else animationDurationSmall + menu.forEachIndexed { index, item -> + val view = findViewById(item.itemId) + val anim = view.animate().setStartDelay(index * delay) + if (isRail) anim.translationX(value) + else anim.translationY(value) + startAnimation(view, anim, 0.5f) + } + } else { + if (isRail) translationX = value + else translationY = value + + menu.forEach { + findViewById(it.itemId).apply { + translationX = 0f + translationY = 0f + } + } + action(value) + } + } + + fun View.animateVisibility(visible: Boolean, animate: Boolean = true) { + if (animations && animate && isVisible != visible) { + isVisible = true + startAnimation( + this, + animate().alpha(if (visible) 1f else 0f).withEndAction { + alpha = if (visible) 1f else 0f + isVisible = visible + } + ) + } else { + alpha = if (visible) 1f else 0f + isVisible = visible + } + } + + fun animateTranslation(view: View, old: Int, newHeight: Int) = view.run { + if (view.animations) { + clearAnimation() + view.translationY = newHeight.toFloat() - old + startAnimation(this, animate().translationY(0f)) + } + } + + private val View.animationDuration: Long + get() = context.applicationContext.run { + MotionUtils.resolveThemeDuration( + this, com.google.android.material.R.attr.motionDurationMedium1, 350 + ).toLong() + } + + private val View.animationDurationSmall: Long + get() = context.applicationContext.run { + MotionUtils.resolveThemeDuration( + this, com.google.android.material.R.attr.motionDurationShort1, 100 + ).toLong() + } + + const val ANIMATIONS_KEY = "animations" + const val SCROLL_ANIMATIONS_KEY = "shared_element" + + private val View.animations + get() = context.applicationContext.run { + getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE).getBoolean(ANIMATIONS_KEY, true) + } + + private val View.scrollAnimations + get() = context.applicationContext.run { + getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE) + .getBoolean(SCROLL_ANIMATIONS_KEY, false) + } + + fun Fragment.setupTransition( + view: View, applyBackground: Boolean = true, axis: Int = MaterialSharedAxis.Z + ) { + if (applyBackground) { + val color = MaterialColors.getColor(view, R.attr.echoBackground, 0) + view.setBackgroundColor(color) + } + + if (view.animations) { +// val transitionName = arguments?.getString("transitionName") +// if (transitionName != null) { +// view.transitionName = transitionName +// val transition = MaterialContainerTransform().apply { +// drawingViewId = id +// setAllContainerColors(color) +// duration = view.animationDuration +// } +// sharedElementEnterTransition = transition +// } + (view as? ViewGroup)?.isTransitionGroup = true + exitTransition = MaterialSharedAxis(axis, true) + reenterTransition = MaterialSharedAxis(axis, false) + enterTransition = MaterialSharedAxis(axis, true) + returnTransition = MaterialSharedAxis(axis, false) + + postponeEnterTransition() + view.doOnPreDraw { startPostponedEnterTransition() } + } + } + + fun View.animatedWithAlpha(delay: Long = 0, vararg anim: Animation) { + if (!animations) return + val set = AnimationSet(true) + set.interpolator = getInterpolator(context) as Interpolator + val alpha = AlphaAnimation(0.0f, 1.0f) + alpha.duration = animationDurationSmall + alpha.startOffset = delay + set.addAnimation(alpha) + anim.forEach { set.addAnimation(it) } + startAnimation(set) + } + + fun View.applyTranslationAndScaleAnimation( + amount: Int, delay: Long = 0 + ) { + if (!animations) return + if (!scrollAnimations) return + val multiplier = amount.sign + val rotateAnimation = RotateAnimation( + 5f * multiplier, 0f, + width.toFloat() / 2, height.toFloat() / 2 + ) + rotateAnimation.duration = animationDuration + val translate = TranslateAnimation( + Animation.RELATIVE_TO_SELF, multiplier * 0.5f, + Animation.RELATIVE_TO_SELF, 0f, + Animation.RELATIVE_TO_SELF, 0f, + Animation.RELATIVE_TO_SELF, 0f, + ) + translate.duration = animationDuration + val from = 1f - 0.5f * multiplier.absoluteValue + val scale = ScaleAnimation( + from, 1f, from, 1f, + Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f + ) + scale.duration = animationDuration + animatedWithAlpha(delay, rotateAnimation, translate, scale) + } + + fun View.applyTranslationYAnimation(amount: Int, delay: Long = 0) { + if (!animations) return + if (!scrollAnimations) return + val multiplier = amount.sign + val translate = TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0f, + Animation.RELATIVE_TO_SELF, 0f, + Animation.RELATIVE_TO_SELF, multiplier * 1.5f, + Animation.RELATIVE_TO_SELF, 0f, + ) + translate.duration = animationDuration + translate.startOffset = delay + animatedWithAlpha(delay, translate) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AutoClearedValue.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AutoClearedValue.kt new file mode 100644 index 0000000..dbc4724 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/AutoClearedValue.kt @@ -0,0 +1,57 @@ +package dev.brahmkshatriya.echo.utils.ui + +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class AutoClearedValue(val fragment: Fragment) : ReadWriteProperty { + private var _value: T? = null + + init { + fragment.addOnDestroyObserver { _value = null } + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + return _value ?: throw IllegalStateException( + "should never call auto-cleared-value get when it might not be available" + ) + } + + override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { + _value = value + } + + class Nullable(val fragment: Fragment) : ReadWriteProperty { + private var _value: T? = null + + override fun getValue(thisRef: Fragment, property: KProperty<*>) = _value + + override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T?) { + _value = value + } + + } + + companion object { + fun Fragment.addOnDestroyObserver(onDestroy: () -> Unit) { + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + viewLifecycleOwnerLiveData.observe(this@addOnDestroyObserver) { + it?.lifecycle?.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + onDestroy() + } + } + ) + } + } + }) + } + + fun Fragment.autoCleared() = AutoClearedValue(this) + fun Fragment.autoClearedNullable() = Nullable(this) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/CheckBoxListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/CheckBoxListener.kt new file mode 100644 index 0000000..ffe56ba --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/CheckBoxListener.kt @@ -0,0 +1,19 @@ +package dev.brahmkshatriya.echo.utils.ui + +import com.google.android.material.checkbox.MaterialCheckBox + +class CheckBoxListener(private val block: (Boolean) -> Unit) : + MaterialCheckBox.OnCheckedStateChangedListener { + var enabled = true + private var checked = false + private fun check(isChecked: Boolean) { + if (checked == isChecked) return + block(isChecked) + } + + override fun onCheckedStateChangedListener(checkBox: MaterialCheckBox, state: Int) { + val isChecked = checkBox.isChecked + if (enabled) check(isChecked) + checked = isChecked + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/FastScrollerHelper.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/FastScrollerHelper.kt new file mode 100644 index 0000000..9f77e99 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/FastScrollerHelper.kt @@ -0,0 +1,36 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.view.View +import androidx.core.widget.NestedScrollView +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx +import me.zhanghai.android.fastscroll.FastScroller +import me.zhanghai.android.fastscroll.FastScrollerBuilder + + +object FastScrollerHelper { + const val SCROLL_BAR = "scroll_bar" + fun View.isScrollBarEnabled() = context.getSettings().getBoolean(SCROLL_BAR, false) + + fun applyTo(view: RecyclerView): FastScroller? { + view.isVerticalScrollBarEnabled = false + if (!view.isScrollBarEnabled()) return null + return FastScrollerBuilder(view).apply { + useMd2Style() + val pad = 8.dpToPx(view.context) + setPadding(pad, pad, pad, pad) + }.build() + } + + fun applyTo(view: NestedScrollView): FastScroller? { + view.isVerticalScrollBarEnabled = false + if (!view.isScrollBarEnabled()) return null + return FastScrollerBuilder(view).apply { + useMd2Style() + val pad = 8.dpToPx(view.context) + setPadding(pad, pad, pad, pad) + }.build() + } + +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GestureListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GestureListener.kt new file mode 100644 index 0000000..50fdd1c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GestureListener.kt @@ -0,0 +1,52 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.graphics.drawable.RippleDrawable +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import java.util.Timer +import kotlin.concurrent.schedule + +interface GestureListener { + val onClick: () -> Unit + val onLongClick: (() -> Unit)? + val onDoubleClick: (() -> Unit)? + + companion object { + fun View.handleGestures(listener: GestureListener) { + val gestureListener = object : GestureDetector.SimpleOnGestureListener() { + private var timer: Timer? = null + private var beingDoubleClicked = false + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (!beingDoubleClicked) listener.onClick() + else return onDoubleTap(e) + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + val onDoubleClick = listener.onDoubleClick ?: return false + isPressed = true + isPressed = false + (background as? RippleDrawable)?.setHotspot(e.x, e.y) + onDoubleClick.invoke() + beingDoubleClicked = true + timer?.cancel() + val timer = Timer() + this.timer = timer + timer.schedule(1000) { beingDoubleClicked = false } + return true + } + + override fun onLongPress(e: MotionEvent) { + listener.onLongClick?.invoke() + } + } + val detector = GestureDetector(context, gestureListener) + setOnTouchListener { _, event -> + detector.onTouchEvent(event) + performClick() + true + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GradientDrawable.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GradientDrawable.kt new file mode 100644 index 0000000..983c466 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/GradientDrawable.kt @@ -0,0 +1,136 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.R.attr.colorPrimary +import android.graphics.BitmapShader +import android.graphics.Color +import android.graphics.ComposeShader +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.PorterDuff +import android.graphics.Shader +import android.graphics.drawable.Drawable +import android.graphics.drawable.PaintDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RectShape +import android.view.View +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.utils.image.BlurTransformation.Companion.blurred +import dev.brahmkshatriya.echo.utils.ui.UiUtils.isRTL + +object GradientDrawable { + fun applyNav( + view: View, + isRail: Boolean, + bottom: Int = 0, + full: Boolean = true, + ) { + val color = MaterialColors.getColor(view, R.attr.navBackground) + val isRTL = !view.context.isRTL() + view.background = PaintDrawable().apply { + setShape(RectShape()) + shaderFactory = object : ShapeDrawable.ShaderFactory() { + override fun resize(width: Int, height: Int): Shader { + val centerY = if (isRail) 1f else (height * 0.5f + bottom) / (height + bottom) + val startX = if (isRail) if (isRTL) width.toFloat() else 0f else width / 2f + val startY = if (isRail) height / 2f else 0f + val endX = if (isRail) if (isRTL) 0f else width.toFloat() else width / 2f + val endY = if (isRail) height / 2f else height.toFloat() + return LinearGradient( + startX, + startY, + endX, + endY, + intArrayOf(if (full) color else Color.TRANSPARENT, color, color), + floatArrayOf(0f, centerY, 1f), + Shader.TileMode.CLAMP + ) + } + } + } + } + + private const val RATIO = 0.33f + private val maxColor = Color.argb(128, 0, 0, 0) + fun createBlurred(view: View, toBlur: Drawable?): Drawable { + val background = MaterialColors.getColor(view, R.attr.echoBackground) + val primary = MaterialColors.getColor(view, colorPrimary) + if (toBlur == null) return background.toDrawable() + val noise = ResourcesCompat.getDrawable( + view.resources, R.drawable.grain_noise, view.context.theme + )!!.toBitmap() + val bitmap = toBlur.run { + toBitmap( + intrinsicWidth.coerceAtLeast(1), + intrinsicHeight.coerceAtLeast(1), + ) + }.blurred(view.context) + return PaintDrawable().apply { + setShape(RectShape()) + paint.shader = null + shaderFactory = object : ShapeDrawable.ShaderFactory() { + override fun resize(width: Int, height: Int): Shader { + val bitmapShader = + BitmapShader(bitmap, Shader.TileMode.MIRROR, Shader.TileMode.MIRROR) + val cropHeight = (height * RATIO).toInt().coerceAtLeast(1) + val scale = maxOf( + width.toFloat() / bitmap.width, + cropHeight.toFloat() / bitmap.height + ) + val matrix = Matrix().apply { + setScale(scale, scale) + } + bitmapShader.setLocalMatrix(matrix) + + val primaryOverlay = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + intArrayOf(ColorUtils.setAlphaComponent(primary, 84), Color.TRANSPARENT), + floatArrayOf(0f, 1f), + Shader.TileMode.CLAMP + ) + val blurredWithPrimary = ComposeShader( + bitmapShader, + primaryOverlay, + PorterDuff.Mode.SRC_OVER + ) + val composed = ComposeShader( + blurredWithPrimary, + LinearGradient( + 0f, + 0f, + 0f, + height.toFloat(), + intArrayOf(maxColor, Color.TRANSPARENT, Color.TRANSPARENT), + floatArrayOf(0f, RATIO, 1f), + Shader.TileMode.CLAMP + ), + PorterDuff.Mode.DST_IN + ) + val withNoise = ComposeShader( + composed, + BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT), + PorterDuff.Mode.DST_IN + ) + val backgroundShader = LinearGradient( + 0f, + 0f, + 0f, + height.toFloat(), + intArrayOf(background, background), + null, + Shader.TileMode.CLAMP + ) + return ComposeShader( + backgroundShader, + withNoise, + PorterDuff.Mode.SRC_OVER + ) + } + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/RulerAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/RulerAdapter.kt new file mode 100644 index 0000000..d2ef725 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/RulerAdapter.kt @@ -0,0 +1,191 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.doOnLayout +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.PagerSnapHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R +import com.google.android.material.color.MaterialColors +import dev.brahmkshatriya.echo.databinding.ItemRulerBinding +import dev.brahmkshatriya.echo.databinding.ItemRulerEmptyBinding +import dev.brahmkshatriya.echo.utils.ui.UiUtils.dpToPx + +class RulerAdapter( + private val listener: Listener +) : RecyclerView.Adapter() { + + interface Listener { + fun intervalText(value: T): String + fun onSelectItem(value: T) + + val selectedColor get() = androidx.appcompat.R.attr.colorPrimary + val unselectedColor get() = R.attr.colorOnSurface + } + + sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { + open fun bind(position: Int) {} + + class Empty( + parent: ViewGroup, binding: ItemRulerEmptyBinding = ItemRulerEmptyBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ViewHolder(binding.root) { + init { + parent.doOnLayout { + binding.root.updateLayoutParams { + val itemWidth = 24.dpToPx(it.context) + width = (it.width - itemWidth) / 2 + } + } + } + } + + class Item( + parent: ViewGroup, + private val getItem: (Int) -> Pair?, + private val listener: Listener, + private val binding: ItemRulerBinding = ItemRulerBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) : ViewHolder(binding.root) { + override fun bind(position: Int) { + val (value, showText) = getItem(position) ?: return + binding.rulerText.text = if (showText) listener.intervalText(value) else "" + unselect() + } + + fun select() { + binding.rulerCard.setCardBackgroundColor( + MaterialColors.getColor(itemView, listener.selectedColor, 0) + ) + } + + fun unselect() { + binding.rulerCard.setCardBackgroundColor( + MaterialColors.getColor(itemView, listener.unselectedColor, 0) + ) + } + } + } + + override fun getItemViewType(position: Int) = when (position) { + 0, itemCount - 1 -> 0 + else -> 1 + } + + private var currentList = listOf?>(null, null) + private fun getItem(position: Int): Pair? { + return currentList.getOrNull(position) + } + + override fun getItemCount() = currentList.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + 0 -> ViewHolder.Empty(parent) + else -> ViewHolder.Item(parent, ::getItem, listener) + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(position) + } + + private var selectedIndex: Int = -1 + @SuppressLint("NotifyDataSetChanged") + fun submitList(list: List>, selected: T) { + currentList = buildList { + add(null) + addAll(list) + add(null) + } + val index = list.indexOfFirst { it.first == selected } + selectedIndex = index + 1 + notifyDataSetChanged() + } + + private var recyclerView: RecyclerView? = null + private var selectedVH: ViewHolder.Item<*>? = null + private fun select() { + val (vh, newIndex) = getMiddleItem() ?: return + if (vh == selectedVH) return + selectedIndex = newIndex + selectedVH?.unselect() + vh.select() + selectedVH = vh + vh.itemView.run { + isHapticFeedbackEnabled = true + performHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK) + } + listener.onSelectItem(getItem(newIndex)!!.first) + } + + private val recyclerListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + select() + } + } + + private fun scrollToMiddle(after: () -> Unit = {}) { + recyclerView?.run { + val manager = layoutManager as LinearLayoutManager + doOnLayout { + val itemWidth = 24.dpToPx(context) + val pad = (width - itemWidth) / 2 + manager.scrollToPositionWithOffset(selectedIndex, pad) + post { + after() + } + } + } + } + + private val snapHelper = PagerSnapHelper() + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + snapHelper.attachToRecyclerView(recyclerView) + scrollToMiddle { + select() + recyclerView.addOnScrollListener(recyclerListener) + } + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + this.recyclerView?.removeOnScrollListener(recyclerListener) + snapHelper.attachToRecyclerView(null) + this.recyclerView = null + } + + private fun getMiddleItem() = recyclerView?.run { + val middle = width * .5f + val layoutManager = layoutManager as LinearLayoutManager + + val firstVisibleIndex = layoutManager.findFirstVisibleItemPosition() + val lastVisibleIndex = layoutManager.findLastVisibleItemPosition() + val visibleIndexes = firstVisibleIndex..lastVisibleIndex + + visibleIndexes.forEach { + if (it == 0 || it == itemCount - 1) return@forEach + val vh = findViewHolderForLayoutPosition(it) ?: return@forEach + val location = IntArray(2) + vh.itemView.getLocationOnScreen(location) + val x = location[0] + val halfWidth = vh.itemView.run { width + marginLeft + marginRight } * .5f + val rightSide = x + halfWidth + val leftSide = x - halfWidth + val isInMiddle = middle in leftSide..rightSide + if (isInMiddle) { + return@run vh as ViewHolder.Item<*> to it + } + } + null + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/SimpleItemSpan.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/SimpleItemSpan.kt new file mode 100644 index 0000000..f70132c --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/SimpleItemSpan.kt @@ -0,0 +1,25 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.content.Context +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View +import dev.brahmkshatriya.echo.R + +class SimpleItemSpan( + val context: Context, + private val onItemClicked: () -> Unit +) : ClickableSpan() { + + override fun onClick(widget: View) { + onItemClicked() + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + context.run { + ds.color = resources.getColor(R.color.button_player, theme) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/UiUtils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/UiUtils.kt new file mode 100644 index 0000000..2c81525 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/UiUtils.kt @@ -0,0 +1,112 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.app.Activity +import android.content.Context +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.UI_MODE_NIGHT_MASK +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.text.TextUtils +import android.view.View +import android.view.WindowManager +import android.widget.TextView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.brahmkshatriya.echo.ui.common.UiViewModel.Companion.BACKGROUND_GRADIENT +import dev.brahmkshatriya.echo.utils.ContextUtils.getSettings +import java.util.Locale +import kotlin.math.max +import kotlin.math.roundToLong + +object UiUtils { + + fun Activity.hideSystemUi(hide: Boolean) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + if (hide) { + controller.hide(WindowInsetsCompat.Type.systemBars()) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + controller.show(WindowInsetsCompat.Type.systemBars()) + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + fun AppBarLayout.configureAppBar(block: (offset: Float) -> Unit) { + val settings = context.getSettings() + val isGradient = settings.getBoolean(BACKGROUND_GRADIENT, true) + val extra = if (isGradient) -191 else 0 + addOnOffsetChangedListener { _, verticalOffset -> + val offset = -verticalOffset / totalScrollRange.toFloat() + background?.mutate()?.alpha = max(0, extra + (offset * 255).toInt()) + runCatching { block(offset) } + } + } + + fun Context.isRTL() = + resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + + fun Context.isLandscape() = + resources.configuration.orientation == ORIENTATION_LANDSCAPE + + fun Context.isNightMode() = + resources.configuration.uiMode and UI_MODE_NIGHT_MASK != UI_MODE_NIGHT_NO + + fun Int.dpToPx(context: Context) = (this * context.resources.displayMetrics.density).toInt() + + fun Context.resolveStyledDimension(attr: Int): Int { + val typed = theme.obtainStyledAttributes(intArrayOf(attr)) + val itemWidth = typed.getDimensionPixelSize(typed.getIndex(0), 0) + return itemWidth + } + + fun Long.toTimeString(): String { + val seconds = (this.toFloat() / 1000).roundToLong() + val minutes = seconds / 60 + val hours = minutes / 60 + return if (hours > 0) { + String.format(Locale.ENGLISH, "%02d:%02d:%02d", hours, minutes % 60, seconds % 60) + } else { + String.format(Locale.ENGLISH, "%02d:%02d", minutes, seconds % 60) + } + } + + fun TextView.marquee() { + isSelected = true + ellipsize = TextUtils.TruncateAt.MARQUEE + maxLines = 1 + marqueeRepeatLimit = -1 + setHorizontallyScrolling(true) + } + + fun BottomSheetDialogFragment.configureBottomBar(bar: View) { + val view = requireView() + val dialog = requireDialog() as BottomSheetDialog + val behavior = dialog.behavior + val barHeight = 72.dpToPx(requireContext()) + var peek = 0 + var toScroll = 0 + var offset = 0f + + val callback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(p0: View, p1: Int) {} + override fun onSlide(p0: View, p1: Float) { + offset = p1.coerceAtLeast(0f) + bar.y = peek + toScroll * offset + } + } + view.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val screen = v.height - barHeight + peek = (behavior.peekHeight - barHeight).coerceAtMost(screen) + toScroll = screen - peek + bar.y = peek + toScroll * offset + } + behavior.addBottomSheetCallback(callback) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/ViewPager2Utils.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/ViewPager2Utils.kt new file mode 100644 index 0000000..32fd2a3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/ViewPager2Utils.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.utils.ui + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager.widget.ViewPager +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback + +object ViewPager2Utils { + + fun ViewPager2.supportBottomSheetBehavior() { + val recycler = getChildAt(0) as RecyclerView + recycler.run { + isNestedScrollingEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + } + } + + fun ViewPager2.registerOnUserPageChangeCallback( + listener: (position: Int, userInitiated: Boolean) -> Unit + ) { + var previousState: Int = -1 + var userScrollChange = false + registerOnPageChangeCallback(object : OnPageChangeCallback() { + + override fun onPageSelected(position: Int) { + listener(position, userScrollChange) + } + + override fun onPageScrollStateChanged(state: Int) { + if (previousState == ViewPager.SCROLL_STATE_DRAGGING && + state == ViewPager.SCROLL_STATE_SETTLING + ) { + userScrollChange = true + } else if (previousState == ViewPager.SCROLL_STATE_SETTLING && + state == ViewPager.SCROLL_STATE_IDLE + ) { + userScrollChange = false + } + previousState = state + } + }) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorListPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorListPreference.kt new file mode 100644 index 0000000..65f6ad5 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorListPreference.kt @@ -0,0 +1,158 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.ItemColorCardBinding + +class ColorListPreference(val fragment: Fragment, var listener: Listener? = null) : + Preference(fragment.requireContext()) { + + fun interface Listener { + fun onColorSelected(color: Int) + } + + private lateinit var colorCardAdapter: ColorCardAdapter + private lateinit var sharedPreferences: SharedPreferences + + init { + layoutResource = R.layout.preference_color_list + } + + private var defaultValue: Int? = null + override fun onSetInitialValue(defaultValue: Any?) { + this.defaultValue = defaultValue as? Int + } + + @SuppressLint("NotifyDataSetChanged") + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val view: View = holder.itemView + view.alpha = if (isEnabled) 1f else 0.66f + + sharedPreferences = preferenceManager.sharedPreferences!! + val addColorCard: MaterialCardView = view.findViewById(R.id.addColor) + val recentColorsRecyclerView: RecyclerView = view.findViewById(R.id.recentColors) + + colorCardAdapter = ColorCardAdapter(getSavedColors(), adapterListener) + recentColorsRecyclerView.adapter = colorCardAdapter + + addColorCard.setOnClickListener { + if (!isEnabled) return@setOnClickListener + ColorPickerDialog().show(fragment.parentFragmentManager, null) + } + + fragment.parentFragmentManager + .setFragmentResultListener("colorPicker", fragment) { _, bundle -> + val color = bundle.getInt("color").takeIf { it != -1 } + ?: return@setFragmentResultListener + addColor(color) + colorCardAdapter.colors = getSavedColors() + colorCardAdapter.notifyDataSetChanged() + } + } + + private val adapterListener = object : ColorCardAdapter.Listener { + @SuppressLint("NotifyDataSetChanged") + override fun onColorSelected(color: Int) { + if (!isEnabled) return + saveSelectedColor(color) + colorCardAdapter.colors = getSavedColors() + colorCardAdapter.notifyDataSetChanged() + listener?.onColorSelected(color) + } + + @SuppressLint("NotifyDataSetChanged") + override fun onColorRemoved(color: Int): Boolean { + if (!isEnabled) return false + removeColor(color) + colorCardAdapter.colors = getSavedColors() + colorCardAdapter.notifyDataSetChanged() + return true + } + } + + class ColorCardAdapter(var colors: List>, val listener: Listener) : + RecyclerView.Adapter() { + interface Listener { + fun onColorSelected(color: Int) + fun onColorRemoved(color: Int): Boolean + } + + inner class ViewHolder(val binding: ItemColorCardBinding) : + RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + listener.onColorSelected(colors[bindingAdapterPosition].second) + } + binding.root.setOnLongClickListener { + listener.onColorRemoved(colors[bindingAdapterPosition].second) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ItemColorCardBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun getItemCount() = colors.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val color = colors[position] + holder.binding.root.setCardBackgroundColor(color.second) + holder.binding.colorSelected.isVisible = color.first + } + + } + + private fun getSelectedColor(): Int? { + val color = sharedPreferences.getInt(key, defaultValue ?: -1) + return color.takeIf { it != -1 } + } + + private fun saveSelectedColor(color: Int?) { + sharedPreferences.edit { + putInt(key, color ?: -1) + } + } + + private fun getSavedColors(): List> { + val selectedColor = getSelectedColor() + val colorString = sharedPreferences.getString("${key}_colors", "") + ?.split(",").orEmpty() + .map { it.toIntOrNull() } - selectedColor + val colors = colorString.mapNotNull { it?.let { false to it } } + return listOfNotNull(selectedColor?.let { true to it }) + colors + } + + private fun List>.inString() = joinToString(",") { it.second.toString() } + + private fun addColor(color: Int) { + val colorList = getSavedColors().toMutableList() + colorList.add(false to color) + sharedPreferences.edit { + putString("${key}_colors", colorList.inString()) + } + } + + private fun removeColor(color: Int) { + val colorList = getSavedColors().toMutableList() + colorList.removeAll { it.second == color } + sharedPreferences.edit { + putString("${key}_colors", colorList.inString()) + } + } + +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorPickerDialog.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorPickerDialog.kt new file mode 100644 index 0000000..4b6e7b2 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/ColorPickerDialog.kt @@ -0,0 +1,59 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import com.madrapps.pikolo.listeners.OnColorSelectionListener +import dev.brahmkshatriya.echo.databinding.DialogColorPickerBinding +import dev.brahmkshatriya.echo.utils.ui.AutoClearedValue.Companion.autoCleared +import kotlin.random.Random + +class ColorPickerDialog : DialogFragment() { + + var binding by autoCleared() + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + binding = DialogColorPickerBinding.inflate(inflater, container, false) + return binding.root + } + + var currentColor: Int? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.colorPickerView.setColorSelectionListener(object : OnColorSelectionListener { + override fun onColorSelected(color: Int) { + currentColor = color + binding.colorCard.setCardBackgroundColor(color) + } + + override fun onColorSelectionEnd(color: Int) {} + override fun onColorSelectionStart(color: Int) {} + }) + binding.addColorButton.setOnClickListener { dismiss() } + binding.randomColorButton.setOnClickListener { setColor(generateRandomColor()) } + setColor(generateRandomColor()) + } + + private fun setColor(color: Int){ + currentColor = color + binding.colorCard.setCardBackgroundColor(color) + binding.colorPickerView.setColor(color) + } + + private fun generateRandomColor(): Int { + val red = Random.nextInt(256) + val green = Random.nextInt(256) + val blue = Random.nextInt(256) + return Color.rgb(red, green, blue) + } + + override fun onDestroy() { + super.onDestroy() + parentFragmentManager.setFragmentResult("colorPicker", Bundle().apply { + putInt("color", currentColor!!) + }) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LoadingPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LoadingPreference.kt new file mode 100644 index 0000000..94380da --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LoadingPreference.kt @@ -0,0 +1,11 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.Preference +import dev.brahmkshatriya.echo.R + +class LoadingPreference(context: Context) : Preference(context) { + init { + layoutResource = R.layout.item_loading + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LongClickPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LongClickPreference.kt new file mode 100644 index 0000000..4f57468 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/LongClickPreference.kt @@ -0,0 +1,22 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder + +open class LongClickPreference(context:Context) : Preference(context) { + private var onLongClickListener: (() -> Unit)? = null + + fun setOnLongClickListener(listener: () -> Unit) { + onLongClickListener = listener + } + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val listener = onLongClickListener ?: return + holder.itemView.setOnLongClickListener { + listener.invoke() + true + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialListPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialListPreference.kt new file mode 100644 index 0000000..c425850 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialListPreference.kt @@ -0,0 +1,38 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.ListPreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R + +class MaterialListPreference(context: Context) : ListPreference(context) { + + private var customSummary: CharSequence? = null + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + customSummary = summary + updateSummary() + } + + override fun onClick() { + MaterialAlertDialogBuilder(context) + .setSingleChoiceItems(entries, entryValues.indexOf(value)) { dialog, index -> + if (callChangeListener(entryValues[index].toString())) runCatching { + setValueIndex(index) + updateSummary() + } + dialog.dismiss() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }.setTitle(title) + .create() + .show() + } + + private fun updateSummary() { + val value = context.getString(R.string.value) + val entry = entry.takeIf { !it.isNullOrEmpty() } ?: context.getString(R.string.value_not_set) + val sum = customSummary?.let { "\n\n$it" } ?: "" + summary = "$value : $entry$sum" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialMultipleChoicePreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialMultipleChoicePreference.kt new file mode 100644 index 0000000..ddb1709 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialMultipleChoicePreference.kt @@ -0,0 +1,54 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.MultiSelectListPreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R + +class MaterialMultipleChoicePreference(context: Context) : MultiSelectListPreference(context) { + + private var customSummary: CharSequence? = null + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + customSummary = summary + updateSummary() + } + + override fun onClick() { + val selectedIndices = BooleanArray(entries.size) { index -> + values.contains(entryValues.getOrNull(index)) + } + + MaterialAlertDialogBuilder(context) + .setMultiChoiceItems(entries, selectedIndices) { _, which, isChecked -> + val value = entryValues.getOrNull(which) + if (isChecked && value != null) { + values.add(value.toString()) + } else { + values.remove(value) + } + } + .setPositiveButton(R.string.okay) { dialog, _ -> + if (callChangeListener(values)) { + setValues(values.toSet()) + updateSummary() + } + dialog.dismiss() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setTitle(title) + .create() + .show() + } + + private fun updateSummary() { + val value = context.getString(R.string.value) + val entry = values.takeIf { it.isNotEmpty() }?.joinToString(", ") { v -> + val index = entryValues.indexOf(v) + if (index >= 0) entries.getOrNull(index).toString() else v + } ?: context.getString(R.string.value_not_set) + val sum = customSummary?.let { "\n\n$it" } ?: "" + summary = "$value : $entry$sum" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialSliderPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialSliderPreference.kt new file mode 100644 index 0000000..83202fe --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialSliderPreference.kt @@ -0,0 +1,103 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.app.AlertDialog +import android.content.Context +import android.widget.EditText +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import dev.brahmkshatriya.echo.R +import kotlin.math.max +import kotlin.math.min + +class MaterialSliderPreference( + context: Context, + private val from: Int, + private val to: Int, + private val steps: Int? = null, + private val allowOverride: Boolean = false +) : Preference(context) { + init { + layoutResource = R.layout.preference_slider + } + + private var customSummary: CharSequence? = null + private var defaultValue: Int? = null + + override fun onSetInitialValue(defaultValue: Any?) { + customSummary = summary + this.defaultValue = defaultValue as? Int + updateSummary() + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val slider = holder.itemView.findViewById(R.id.preferences_slider) + val current = getPersistedInt(defaultValue ?: from) + val min = if (allowOverride) min(from, current) else from + slider.valueFrom = min.toFloat() + val max = if (allowOverride) max(to, current) else to + slider.valueTo = max.toFloat() + slider.value = min(max(current, min), max).toFloat() + slider.stepSize = steps?.toFloat() ?: 1f + + slider.addOnChangeListener { _, value, byUser -> + persistInt(value.toInt()) + slider.post { + runCatching { + updateSummary() + if (allowOverride && !dialogOpened && byUser && value == slider.valueTo) + showOverrideDialog(slider, value) + } + } + } + + if (allowOverride) holder.itemView.setOnClickListener { + showOverrideDialog(slider, slider.value) + } + } + + private var dialogOpened = false + private fun showOverrideDialog(slider: Slider, value: Float) { + dialogOpened = true + val dialog = MaterialAlertDialogBuilder(context) + .setView(R.layout.item_edit_text) + .setPositiveButton(R.string.okay, null) + .setNegativeButton(R.string.cancel, null) + .setTitle(title) + .create() + + dialog.setOnShowListener { + val editText = dialog.findViewById(R.id.edit_text) + editText?.inputType = android.text.InputType.TYPE_CLASS_NUMBER + editText?.setText(value.toInt().toString()) + editText?.hint = customSummary + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val newMaxValue = editText?.text?.toString()?.toIntOrNull() + if (newMaxValue != null && (allowOverride || newMaxValue in from..to)) { + slider.valueTo = newMaxValue.toFloat() + slider.value = newMaxValue.toFloat() + dialog.dismiss() + } else { + editText?.error = context.getString(R.string.error_x, if (!allowOverride) "$from - $to" else "") + + } + } + + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + dialog.dismiss() + } + } + dialog.setOnDismissListener { dialogOpened = false } + dialog.show() + } + + private fun updateSummary() { + val value = context.getString(R.string.value) + val entry = getPersistedInt(defaultValue ?: 0) + val sum = customSummary?.let { "\n\n$it" } ?: "" + summary = "$value : $entry$sum".trimIndent() + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialTextInputPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialTextInputPreference.kt new file mode 100644 index 0000000..a81184a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/MaterialTextInputPreference.kt @@ -0,0 +1,57 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.preference.EditTextPreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.brahmkshatriya.echo.R + +class MaterialTextInputPreference(context: Context) : EditTextPreference(context) { + + private var customSummary: CharSequence? = null + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + customSummary = summary + updateSummary() + } + + + override fun onClick() { + val dialog = MaterialAlertDialogBuilder(context) + .setView(R.layout.item_edit_text) + .setPositiveButton(R.string.okay, null) + .setNegativeButton(R.string.cancel, null) + .setTitle(title) + .create() + + dialog.setOnShowListener { + val editText = dialog.findViewById(R.id.edit_text) + editText?.setText(text) + editText?.hint = customSummary + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val newText = editText?.text?.toString() + if (callChangeListener(newText)) { + text = newText + updateSummary() + dialog.dismiss() + } + } + + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + dialog.dismiss() + } + } + + dialog.show() + } + + private fun updateSummary() { + val value = context.getString(R.string.value) + val entry = text.takeIf { !it.isNullOrEmpty() } ?: context.getString(R.string.value_not_set) + val sum = customSummary?.let { "\n\n$it" } ?: "" + summary = "$value : $entry$sum" + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/SwitchLongClickPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/SwitchLongClickPreference.kt new file mode 100644 index 0000000..5d248ba --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/SwitchLongClickPreference.kt @@ -0,0 +1,23 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreferenceCompat + +class SwitchLongClickPreference(context: Context) : SwitchPreferenceCompat(context) { + private var onLongClickListener: (() -> Unit)? = null + + fun setOnLongClickListener(listener: () -> Unit) { + onLongClickListener = listener + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val listener = onLongClickListener ?: return + holder.itemView.setOnLongClickListener { + listener.invoke() + true + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/TransitionPreference.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/TransitionPreference.kt new file mode 100644 index 0000000..685c65a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/prefs/TransitionPreference.kt @@ -0,0 +1,15 @@ +package dev.brahmkshatriya.echo.utils.ui.prefs + +import android.content.Context +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder + +class TransitionPreference( + context: Context +) : Preference(context) { + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.itemView.id = key.hashCode() + holder.itemView.transitionName = key + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimListAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimListAdapter.kt new file mode 100644 index 0000000..c86c386 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimListAdapter.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.utils.ui.scrolling + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation + +abstract class ScrollAnimListAdapter( + diffCallback: DiffUtil.ItemCallback +) : ListAdapter(diffCallback) { + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.itemView.applyTranslationYAnimation(scrollY) + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + var scrollY: Int = 0 + val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollY = dy + onEachViewHolder { scrollAmount = dy } + } + } + + @Suppress("UNCHECKED_CAST") + fun onEachViewHolder(action: VH.() -> Unit) { + recyclerView?.let { rv -> + for (i in 0 until rv.childCount) { + val holder = rv.getChildViewHolder(rv.getChildAt(i)) as? VH + if (holder?.bindingAdapter == this) holder.action() + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimLoadStateAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimLoadStateAdapter.kt new file mode 100644 index 0000000..1a06e29 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimLoadStateAdapter.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.utils.ui.scrolling + +import androidx.annotation.CallSuper +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation + +abstract class ScrollAnimLoadStateAdapter : LoadStateAdapter() { + + @CallSuper + override fun onBindViewHolder(holder: T, loadState: LoadState) { + holder.itemView.applyTranslationYAnimation(scrollY) + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + var scrollY: Int = 0 + val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollY = dy + onEachViewHolder { scrollAmount = dy } + } + } + + @Suppress("UNCHECKED_CAST") + fun onEachViewHolder(action: T.() -> Unit) { + recyclerView?.let { rv -> + for (i in 0 until rv.childCount) { + val holder = rv.getChildViewHolder(rv.getChildAt(i)) as? T + if (holder?.bindingAdapter == this) holder.action() + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimPagingAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimPagingAdapter.kt new file mode 100644 index 0000000..e66fa5e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimPagingAdapter.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.utils.ui.scrolling + +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation + +abstract class ScrollAnimPagingAdapter( + diffCallback: DiffUtil.ItemCallback +) : PagingDataAdapter(diffCallback) { + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.itemView.applyTranslationYAnimation(scrollY) + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + var scrollY: Int = 0 + val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollY = dy + onEachViewHolder { scrollAmount = dy } + } + } + + @Suppress("UNCHECKED_CAST") + fun onEachViewHolder(action: VH.() -> Unit) { + recyclerView?.let { rv -> + for (i in 0 until rv.childCount) { + val holder = rv.getChildViewHolder(rv.getChildAt(i)) as? VH + if (holder?.bindingAdapter == this) holder.action() + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimRecyclerAdapter.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimRecyclerAdapter.kt new file mode 100644 index 0000000..d0102ee --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimRecyclerAdapter.kt @@ -0,0 +1,42 @@ +package dev.brahmkshatriya.echo.utils.ui.scrolling + +import androidx.annotation.CallSuper +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.utils.ui.AnimationUtils.applyTranslationYAnimation + +abstract class ScrollAnimRecyclerAdapter : RecyclerView.Adapter() { + + @CallSuper + override fun onBindViewHolder(holder: T, position: Int) { + holder.itemView.applyTranslationYAnimation(scrollY) + } + + var recyclerView: RecyclerView? = null + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + recyclerView.addOnScrollListener(scrollListener) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnScrollListener(scrollListener) + this.recyclerView = null + } + + var scrollY: Int = 0 + val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + scrollY = dy + onEachViewHolder { scrollAmount = dy } + } + } + + @Suppress("UNCHECKED_CAST") + fun onEachViewHolder(action: T.() -> Unit) { + recyclerView?.let { rv -> + for (i in 0 until rv.childCount) { + val holder = rv.getChildViewHolder(rv.getChildAt(i)) as? T + if (holder?.bindingAdapter == this) holder.action() + } + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimViewHolder.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimViewHolder.kt new file mode 100644 index 0000000..6b990c3 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/utils/ui/scrolling/ScrollAnimViewHolder.kt @@ -0,0 +1,8 @@ +package dev.brahmkshatriya.echo.utils.ui.scrolling + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +abstract class ScrollAnimViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var scrollAmount: Int = 0 +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/BaseWidget.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/BaseWidget.kt new file mode 100644 index 0000000..72b29aa --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/BaseWidget.kt @@ -0,0 +1,225 @@ +package dev.brahmkshatriya.echo.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.widget.RemoteViews +import androidx.core.os.bundleOf +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.extensions.MediaState +import dev.brahmkshatriya.echo.playback.MediaItemUtils.state +import dev.brahmkshatriya.echo.playback.PlayerCommands.likeCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.repeatCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.repeatOffCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.repeatOneCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.resumeCommand +import dev.brahmkshatriya.echo.playback.PlayerCommands.unlikeCommand +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.getPendingIntent +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverIndex +import dev.brahmkshatriya.echo.playback.ResumptionUtils.recoverTracks +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +abstract class BaseWidget : AppWidgetProvider(), KoinComponent { + abstract val clazz: Class<*> + private val key by lazy { clazz.name } + + private val app by inject() + override fun onEnabled(context: Context) { + println("Widget enabled $this") + ControllerHelper.register(app, key) { + updateWidgets(app.context) + } + } + + override fun onDisabled(context: Context) { + println("Widget disabled $this") + ControllerHelper.unregister(key) + } + + override fun onUpdate( + context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray?, + ) { + println("Widget update ${appWidgetIds?.toList()} $this") + } + + override fun onReceive(context: Context?, intent: Intent?) { + ControllerHelper.register(app, key) { + updateWidgets(app.context) + } + val controller = ControllerHelper.controller ?: return + println("Controller is $controller") + when (intent?.action) { + ACTION_PLAY_PAUSE -> controller.run { + prepare() + playWhenReady = !playWhenReady + } + + ACTION_PREVIOUS -> controller.seekToPrevious() + ACTION_NEXT -> controller.seekToNext() + ACTION_LIKE -> controller.sendCustomCommand(likeCommand, Bundle.EMPTY) + ACTION_UNLIKE -> controller.sendCustomCommand(unlikeCommand, Bundle.EMPTY) + ACTION_REPEAT -> controller.sendCustomCommand(repeatCommand, Bundle.EMPTY) + ACTION_REPEAT_OFF -> controller.sendCustomCommand(repeatOffCommand, Bundle.EMPTY) + ACTION_REPEAT_ONE -> controller.sendCustomCommand(repeatOneCommand, Bundle.EMPTY) + ACTION_RESUME -> controller.run { + sendCustomCommand(resumeCommand, bundleOf("cleared" to false)) + playWhenReady = true + } + + else -> super.onReceive(context, intent) + } + } + + + override fun onAppWidgetOptionsChanged( + context: Context?, + appWidgetManager: AppWidgetManager?, + appWidgetId: Int, + newOptions: Bundle?, + ) { + context ?: return + appWidgetManager ?: return + ControllerHelper.register(app, key) { + updateWidgets(app.context) + } + val controller = ControllerHelper.controller + val image = ControllerHelper.image + val views = updatedViews(controller, image, context, appWidgetId) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + fun updateWidgets(context: Context) { + val controller = ControllerHelper.controller + val image = ControllerHelper.image + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, clazz) + ) + appWidgetIds.forEach { appWidgetId -> + val views = updatedViews(controller, image, context, appWidgetId) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + abstract fun updatedViews( + controller: MediaController?, image: Bitmap?, context: Context, appWidgetId: Int, + ): RemoteViews + + companion object { + const val ACTION_PLAY_PAUSE = "dev.brahmkshatriya.echo.widget.PLAY_PAUSE" + const val ACTION_PREVIOUS = "dev.brahmkshatriya.echo.widget.PREVIOUS" + const val ACTION_NEXT = "dev.brahmkshatriya.echo.widget.NEXT" + const val ACTION_LIKE = "dev.brahmkshatriya.echo.widget.LIKE" + const val ACTION_UNLIKE = "dev.brahmkshatriya.echo.widget.UNLIKE" + const val ACTION_REPEAT = "dev.brahmkshatriya.echo.widget.REPEAT" + const val ACTION_REPEAT_OFF = "dev.brahmkshatriya.echo.widget.REPEAT_OFF" + const val ACTION_REPEAT_ONE = "dev.brahmkshatriya.echo.widget.REPEAT_ONE" + const val ACTION_RESUME = "dev.brahmkshatriya.echo.widget.RESUME" + + fun Context.createIntent(clazz: Class<*>, action: String) = PendingIntent.getBroadcast( + this, 0, Intent(this, clazz).apply { + this.action = action + }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + )!! + + fun updateView( + clazz: Class<*>, + controller: MediaController?, + image: Bitmap?, + context: Context, + views: RemoteViews, + ) { + val current = controller?.currentMediaItem + val item = current?.state ?: context.run { + val list = recoverTracks().orEmpty() + val index = recoverIndex() ?: 0 + list.getOrNull(index)?.first + } + val title = item?.item?.title + val artist = item?.item?.artists?.takeIf { it.isNotEmpty() } + ?.joinToString(", ") { it.name } + views.setTextViewText(R.id.trackTitle, title ?: context.getString(R.string.so_empty)) + views.setTextViewText( + R.id.trackArtist, + artist ?: context.getString(R.string.unknown).takeIf { title != null } + ) + val image = image?.run { copy(config ?: Bitmap.Config.ARGB_8888, false) } + if (image == null) views.setImageViewResource(R.id.trackCover, R.drawable.art_music) + else views.setImageViewBitmap(R.id.trackCover, image) + + views.setOnClickPendingIntent(android.R.id.background, getPendingIntent(context)) + + val isPlaying = if (current != null) controller.playWhenReady else false + views.setOnClickPendingIntent( + R.id.playPauseButton, context.createIntent( + clazz, + if (current != null) { + if (isPlaying) ACTION_PLAY_PAUSE else ACTION_PLAY_PAUSE + } else ACTION_RESUME + ) + ) + views.setFloat(R.id.playPauseButton, "setAlpha", if (item != null) 1f else 0.5f) + + views.setImageViewResource( + R.id.playPauseButton, + if (isPlaying) R.drawable.ic_pause_48dp else R.drawable.ic_play_48dp + ) + + views.setOnClickPendingIntent( + R.id.nextButton, context.createIntent(clazz, ACTION_NEXT) + ) + views.setFloat( + R.id.nextButton, + "setAlpha", + if (controller?.hasNextMediaItem() == true) 1f else 0.5f + ) + views.setOnClickPendingIntent( + R.id.previousButton, context.createIntent(clazz, ACTION_PREVIOUS) + ) + views.setFloat( + R.id.previousButton, + "setAlpha", + if ((controller?.currentMediaItemIndex ?: -1) >= 0) 1f else 0.5f + ) + + val isLiked = (item as? MediaState.Loaded)?.isLiked ?: false + views.setOnClickPendingIntent( + R.id.likeButton, + context.createIntent(clazz, if (isLiked) ACTION_UNLIKE else ACTION_LIKE) + ) + views.setImageViewResource( + R.id.likeButton, + if (isLiked) R.drawable.ic_favorite_filled_20dp else R.drawable.ic_favorite_20dp + ) + + val repeatMode = controller?.repeatMode ?: 0 + views.setOnClickPendingIntent( + R.id.repeatButton, context.createIntent( + clazz, + when (repeatMode) { + Player.REPEAT_MODE_OFF -> ACTION_REPEAT + Player.REPEAT_MODE_ALL -> ACTION_REPEAT_ONE + else -> ACTION_REPEAT_OFF + } + ) + ) + + views.setImageViewResource( + R.id.repeatButton, when (repeatMode) { + Player.REPEAT_MODE_OFF -> R.drawable.ic_repeat_20dp + Player.REPEAT_MODE_ONE -> R.drawable.ic_repeat_one_20dp + else -> R.drawable.ic_repeat_on_20dp + } + ) + } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/ControllerHelper.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/ControllerHelper.kt new file mode 100644 index 0000000..6c0d56a --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/ControllerHelper.kt @@ -0,0 +1,45 @@ +package dev.brahmkshatriya.echo.widget + +import android.graphics.Bitmap +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.di.App +import dev.brahmkshatriya.echo.playback.PlayerService.Companion.getController + +object ControllerHelper { + + var controller: MediaController? = null + var callback: (() -> Unit)? = null + var image: Bitmap? = null + var listener: WidgetPlayerListener? = null + + val map = mutableMapOf Unit>() + fun register(app: App, key: String, updateCallback: () -> Unit) { + map[key] = updateCallback + if (callback != null) return + callback = getController(app.context) { + controller = it + val playerListener = WidgetPlayerListener { img -> + image = img + updateWidgets() + } + listener = playerListener + it.addListener(playerListener) + playerListener.controller = it + updateWidgets() + } + } + + fun unregister(key: String) { + map.remove(key) + if (map.isNotEmpty()) return + callback?.invoke() + controller = null + listener?.removed() + listener = null + image = null + } + + fun updateWidgets() { + map.values.forEach { it() } + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetCircle.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetCircle.kt new file mode 100644 index 0000000..f740acc --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetCircle.kt @@ -0,0 +1,19 @@ +package dev.brahmkshatriya.echo.widget + +import android.content.Context +import android.graphics.Bitmap +import android.widget.RemoteViews +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.R + +class WidgetCircle : BaseWidget() { + override val clazz = this::class.java + override fun updatedViews( + controller: MediaController?, image: Bitmap?, context: Context, appWidgetId: Int + ): RemoteViews { + val packageName = context.packageName + val view = RemoteViews(packageName, R.layout.widget_circle) + updateView(clazz, controller, image, context, view) + return view + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetHorizontal.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetHorizontal.kt new file mode 100644 index 0000000..f6d9fb8 --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetHorizontal.kt @@ -0,0 +1,41 @@ +package dev.brahmkshatriya.echo.widget + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.util.SizeF +import android.widget.RemoteViews +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.R + +class WidgetHorizontal : BaseWidget() { + + override val clazz: Class<*> = this::class.java + override fun updatedViews( + controller: MediaController?, image: Bitmap?, context: Context, appWidgetId: Int + ): RemoteViews { + val packageName = context.packageName + val narrowShort = RemoteViews(packageName, R.layout.widget_horizontal_narrow_short) + val narrowTall = RemoteViews(packageName, R.layout.widget_horizontal_narrow_tall) + val wideShort = RemoteViews(packageName, R.layout.widget_horizontal_wide_short) + val wideTall = RemoteViews(packageName, R.layout.widget_horizontal_wide_tall) + + val views = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + updateView(clazz, controller, image, context, narrowShort) + narrowShort + } else { + val widgetLayout = mapOf( + SizeF(0f, 0f) to narrowShort, + SizeF(0f, 128f) to narrowTall, + SizeF(360f, 0f) to wideShort, + SizeF(360f, 128f) to wideTall, + ) + widgetLayout.forEach { (_, u) -> + updateView(clazz, controller, image, context, u) + } + RemoteViews(widgetLayout) + } + return views + } + +} diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetPlayerListener.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetPlayerListener.kt new file mode 100644 index 0000000..c8da5df --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetPlayerListener.kt @@ -0,0 +1,77 @@ +package dev.brahmkshatriya.echo.widget + +import android.graphics.Bitmap +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.session.MediaController +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import dev.brahmkshatriya.echo.playback.PlayerCommands.imageCommand +import dev.brahmkshatriya.echo.utils.Serializer.getParcel + +class WidgetPlayerListener( + private val update: (Bitmap?) -> Unit +) : Player.Listener { + + var controller: MediaController? = null + private var result: ListenableFuture? = null + private var image: Bitmap? = null + private fun getImage() { + result?.cancel(true) + val future = controller?.sendCustomCommand(imageCommand, Bundle.EMPTY) + future?.addListener({ + val result = future.get() + if (result?.resultCode == SessionResult.RESULT_SUCCESS) { + image = result.extras.getParcel("image") + update(image) + } + }, MoreExecutors.directExecutor()) + result = future + } + + fun removed() { + result?.cancel(true) + result = null + controller?.removeListener(this) + controller = null + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + getImage() + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + getImage() + update(image) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + update(image) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + update(image) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + update(image) + } + + override fun onIsLoadingChanged(isLoading: Boolean) { + update(image) + } + + override fun onRepeatModeChanged(repeatMode: Int) { + update(image) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int + ) { + update(image) + } +} \ No newline at end of file diff --git a/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetVertical.kt b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetVertical.kt new file mode 100644 index 0000000..468ac6e --- /dev/null +++ b/apk/android/echo/src/main/java/dev/brahmkshatriya/echo/widget/WidgetVertical.kt @@ -0,0 +1,37 @@ +package dev.brahmkshatriya.echo.widget + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.util.SizeF +import android.widget.RemoteViews +import androidx.media3.session.MediaController +import dev.brahmkshatriya.echo.R + +class WidgetVertical : BaseWidget() { + + override val clazz: Class<*> = this::class.java + override fun updatedViews( + controller: MediaController?, image: Bitmap?, context: Context, appWidgetId: Int + ): RemoteViews { + val packageName = context.packageName + val large = RemoteViews(packageName, R.layout.widget_vertical_large) + val small = RemoteViews(packageName, R.layout.widget_vertical_small) + + val views = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + updateView(clazz, controller, image, context, small) + small + } else { + val widgetLayout = mapOf( + SizeF(0f, 0f) to small, + SizeF(256f, 128f) to large, + ) + widgetLayout.forEach { (_, u) -> + updateView(clazz, controller, image, context, u) + } + RemoteViews(widgetLayout) + } + return views + } + +} diff --git a/apk/android/echo/src/main/res/color/button_player.xml b/apk/android/echo/src/main/res/color/button_player.xml new file mode 100644 index 0000000..8d62a6a --- /dev/null +++ b/apk/android/echo/src/main/res/color/button_player.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/color/button_player_more.xml b/apk/android/echo/src/main/res/color/button_player_more.xml new file mode 100644 index 0000000..3cd81ef --- /dev/null +++ b/apk/android/echo/src/main/res/color/button_player_more.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/color/button_player_ripple.xml b/apk/android/echo/src/main/res/color/button_player_ripple.xml new file mode 100644 index 0000000..d36318f --- /dev/null +++ b/apk/android/echo/src/main/res/color/button_player_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/color/nav_bar_icon.xml b/apk/android/echo/src/main/res/color/nav_bar_icon.xml new file mode 100644 index 0000000..45ac94c --- /dev/null +++ b/apk/android/echo/src/main/res/color/nav_bar_icon.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable-land/gradient_track.xml b/apk/android/echo/src/main/res/drawable-land/gradient_track.xml new file mode 100644 index 0000000..4876191 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable-land/gradient_track.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_bookmark.xml b/apk/android/echo/src/main/res/drawable/anim_bookmark.xml new file mode 100644 index 0000000..0f5a02a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_bookmark.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_check_circle.xml b/apk/android/echo/src/main/res/drawable/anim_check_circle.xml new file mode 100644 index 0000000..565f9bd --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_check_circle.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_heart_40dp.xml b/apk/android/echo/src/main/res/drawable/anim_heart_40dp.xml new file mode 100644 index 0000000..27b3690 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_heart_40dp.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_hide.xml b/apk/android/echo/src/main/res/drawable/anim_hide.xml new file mode 100644 index 0000000..ba833dd --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_hide.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_home.xml b/apk/android/echo/src/main/res/drawable/anim_home.xml new file mode 100644 index 0000000..d124e13 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_home.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_now_playing.xml b/apk/android/echo/src/main/res/drawable/anim_now_playing.xml new file mode 100644 index 0000000..395aa8f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_now_playing.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/anim_play_pause.xml b/apk/android/echo/src/main/res/drawable/anim_play_pause.xml new file mode 100644 index 0000000..9441a84 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_play_pause.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_play_pause_48dp.xml b/apk/android/echo/src/main/res/drawable/anim_play_pause_48dp.xml new file mode 100644 index 0000000..a94486f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_play_pause_48dp.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_search.xml b/apk/android/echo/src/main/res/drawable/anim_search.xml new file mode 100644 index 0000000..a0df5a4 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_search.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/anim_shuffle.xml b/apk/android/echo/src/main/res/drawable/anim_shuffle.xml new file mode 100644 index 0000000..b85af9b --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/anim_shuffle.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/app_widget_gradient_background.xml b/apk/android/echo/src/main/res/drawable/app_widget_gradient_background.xml new file mode 100644 index 0000000..57d890c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/app_widget_gradient_background.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/app_widget_image_background.xml b/apk/android/echo/src/main/res/drawable/app_widget_image_background.xml new file mode 100644 index 0000000..9aad2ed --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/app_widget_image_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/app_widget_inner_view_background.xml b/apk/android/echo/src/main/res/drawable/app_widget_inner_view_background.xml new file mode 100644 index 0000000..de4cd27 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/app_widget_inner_view_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/art_add_photo.xml b/apk/android/echo/src/main/res/drawable/art_add_photo.xml new file mode 100644 index 0000000..b0f4fba --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_add_photo.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/art_album.xml b/apk/android/echo/src/main/res/drawable/art_album.xml new file mode 100644 index 0000000..7f08b0b --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_album.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_artist.xml b/apk/android/echo/src/main/res/drawable/art_artist.xml new file mode 100644 index 0000000..636579d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_artist.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_brand.xml b/apk/android/echo/src/main/res/drawable/art_brand.xml new file mode 100644 index 0000000..5650c53 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_brand.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/art_edit_note.xml b/apk/android/echo/src/main/res/drawable/art_edit_note.xml new file mode 100644 index 0000000..08b42e7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_edit_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_library_music.xml b/apk/android/echo/src/main/res/drawable/art_library_music.xml new file mode 100644 index 0000000..11d3dcb --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_library_music.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_music.xml b/apk/android/echo/src/main/res/drawable/art_music.xml new file mode 100644 index 0000000..dbfdd44 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_music.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_problem.xml b/apk/android/echo/src/main/res/drawable/art_problem.xml new file mode 100644 index 0000000..f06c305 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_problem.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_sensors.xml b/apk/android/echo/src/main/res/drawable/art_sensors.xml new file mode 100644 index 0000000..2648755 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_sensors.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/art_shivam.xml b/apk/android/echo/src/main/res/drawable/art_shivam.xml new file mode 100644 index 0000000..ad1e68e --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_shivam.xml @@ -0,0 +1,14 @@ + + + + diff --git a/apk/android/echo/src/main/res/drawable/art_splash_anim.xml b/apk/android/echo/src/main/res/drawable/art_splash_anim.xml new file mode 100644 index 0000000..fa9d3bd --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_splash_anim.xml @@ -0,0 +1,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/art_twelve_sided_cookie.xml b/apk/android/echo/src/main/res/drawable/art_twelve_sided_cookie.xml new file mode 100644 index 0000000..2761daf --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/art_twelve_sided_cookie.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/dialog_bg.xml b/apk/android/echo/src/main/res/drawable/dialog_bg.xml new file mode 100644 index 0000000..4f453e3 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/dialog_bg.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/gradient_track.xml b/apk/android/echo/src/main/res/drawable/gradient_track.xml new file mode 100644 index 0000000..3c73177 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/gradient_track.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/grain_noise.png b/apk/android/echo/src/main/res/drawable/grain_noise.png new file mode 100644 index 0000000..2b538eb Binary files /dev/null and b/apk/android/echo/src/main/res/drawable/grain_noise.png differ diff --git a/apk/android/echo/src/main/res/drawable/ic_account_circle.xml b/apk/android/echo/src/main/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000..5d2f5e2 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_account_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_account_circle_32dp.xml b/apk/android/echo/src/main/res/drawable/ic_account_circle_32dp.xml new file mode 100644 index 0000000..ab62e4d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_account_circle_32dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_add_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_add_20dp.xml new file mode 100644 index 0000000..8d8fc20 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_add_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_add_circle.xml b/apk/android/echo/src/main/res/drawable/ic_add_circle.xml new file mode 100644 index 0000000..865e918 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_add_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_album.xml b/apk/android/echo/src/main/res/drawable/ic_album.xml new file mode 100644 index 0000000..033bbff --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_album.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_article.xml b/apk/android/echo/src/main/res/drawable/ic_article.xml new file mode 100644 index 0000000..e9567e0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_article.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_artist.xml b/apk/android/echo/src/main/res/drawable/ic_artist.xml new file mode 100644 index 0000000..49db20b --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_artist.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_autorenew.xml b/apk/android/echo/src/main/res/drawable/ic_autorenew.xml new file mode 100644 index 0000000..1983f11 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_autorenew.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_back.xml b/apk/android/echo/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..0dec1a6 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_bookmark_anim.xml b/apk/android/echo/src/main/res/drawable/ic_bookmark_anim.xml new file mode 100644 index 0000000..41d2325 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_bookmark_anim.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_bookmark_filled.xml b/apk/android/echo/src/main/res/drawable/ic_bookmark_filled.xml new file mode 100644 index 0000000..2a704fa --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_bookmark_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_bookmark_outline.xml b/apk/android/echo/src/main/res/drawable/ic_bookmark_outline.xml new file mode 100644 index 0000000..8277fe5 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_bookmark_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_cancel.xml b/apk/android/echo/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..b703c1f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_check_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_check_20dp.xml new file mode 100644 index 0000000..5e67752 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_check_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_check_circle.xml b/apk/android/echo/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..000a2a9 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_check_circle_filled.xml b/apk/android/echo/src/main/res/drawable/ic_check_circle_filled.xml new file mode 100644 index 0000000..31e319f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_check_circle_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_chevron_forward.xml b/apk/android/echo/src/main/res/drawable/ic_chevron_forward.xml new file mode 100644 index 0000000..0d1a6a7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_chevron_forward.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_close.xml b/apk/android/echo/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..d3990c0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_close_small.xml b/apk/android/echo/src/main/res/drawable/ic_close_small.xml new file mode 100644 index 0000000..915ce33 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_close_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_copy.xml b/apk/android/echo/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..6a0293e --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_delete.xml b/apk/android/echo/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..ba8815f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_discord.xml b/apk/android/echo/src/main/res/drawable/ic_discord.xml new file mode 100644 index 0000000..e1c289a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_download_for_offline.xml b/apk/android/echo/src/main/res/drawable/ic_download_for_offline.xml new file mode 100644 index 0000000..0355713 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_download_for_offline.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_downloading.xml b/apk/android/echo/src/main/res/drawable/ic_downloading.xml new file mode 100644 index 0000000..b84b498 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_downloading.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_drag_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_drag_20dp.xml new file mode 100644 index 0000000..7f9714e --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_drag_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_edit_note.xml b/apk/android/echo/src/main/res/drawable/ic_edit_note.xml new file mode 100644 index 0000000..cb672a4 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_edit_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_email.xml b/apk/android/echo/src/main/res/drawable/ic_email.xml new file mode 100644 index 0000000..da5f7e6 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_email.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_equalizer.xml b/apk/android/echo/src/main/res/drawable/ic_equalizer.xml new file mode 100644 index 0000000..5297082 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_equalizer.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_expand_more.xml b/apk/android/echo/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..ec52e3d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_explicit.xml b/apk/android/echo/src/main/res/drawable/ic_explicit.xml new file mode 100644 index 0000000..6708b86 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_explicit.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_extension.xml b/apk/android/echo/src/main/res/drawable/ic_extension.xml new file mode 100644 index 0000000..ade0b6d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_extension.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_extension_32dp.xml b/apk/android/echo/src/main/res/drawable/ic_extension_32dp.xml new file mode 100644 index 0000000..57a9011 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_extension_32dp.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_favorite_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_favorite_20dp.xml new file mode 100644 index 0000000..2d60349 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_favorite_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_favorite_filled_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_favorite_filled_20dp.xml new file mode 100644 index 0000000..03685cd --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_favorite_filled_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_files.xml b/apk/android/echo/src/main/res/drawable/ic_files.xml new file mode 100644 index 0000000..a4705e9 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_files.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_github.xml b/apk/android/echo/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..c2cb8a6 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_github.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_heart_filled_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_heart_filled_40dp.xml new file mode 100644 index 0000000..2448542 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_heart_filled_40dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_heart_filled_to_outline_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_heart_filled_to_outline_40dp.xml new file mode 100644 index 0000000..2f26a85 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_heart_filled_to_outline_40dp.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_heart_outline_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_heart_outline_40dp.xml new file mode 100644 index 0000000..7c9392d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_heart_outline_40dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_heart_outline_to_filled_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_heart_outline_to_filled_40dp.xml new file mode 100644 index 0000000..71eada6 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_heart_outline_to_filled_40dp.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_hide.xml b/apk/android/echo/src/main/res/drawable/ic_hide.xml new file mode 100644 index 0000000..b52265f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_hide.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_high_quality.xml b/apk/android/echo/src/main/res/drawable/ic_high_quality.xml new file mode 100644 index 0000000..bb790a0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_high_quality.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_history.xml b/apk/android/echo/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..4f18958 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_history.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_home_anim.xml b/apk/android/echo/src/main/res/drawable/ic_home_anim.xml new file mode 100644 index 0000000..b5a13c1 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_home_anim.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_home_filled.xml b/apk/android/echo/src/main/res/drawable/ic_home_filled.xml new file mode 100644 index 0000000..d72bc1a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_home_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_home_outline.xml b/apk/android/echo/src/main/res/drawable/ic_home_outline.xml new file mode 100644 index 0000000..34535d0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_home_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_incognito.xml b/apk/android/echo/src/main/res/drawable/ic_incognito.xml new file mode 100644 index 0000000..8876e17 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_incognito.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_info.xml b/apk/android/echo/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..174293d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_input.xml b/apk/android/echo/src/main/res/drawable/ic_input.xml new file mode 100644 index 0000000..5164e0d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_input.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_keyboard_arrow_down.xml b/apk/android/echo/src/main/res/drawable/ic_keyboard_arrow_down.xml new file mode 100644 index 0000000..dc35e7d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_keyboard_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_language.xml b/apk/android/echo/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..67483d3 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_language.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_launcher_background.xml b/apk/android/echo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..eed5c32 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_launcher_foreground.xml b/apk/android/echo/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..01bc12b --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_launcher_monochrome.xml b/apk/android/echo/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..5c66f99 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_library_music.xml b/apk/android/echo/src/main/res/drawable/ic_library_music.xml new file mode 100644 index 0000000..71febec --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_login.xml b/apk/android/echo/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..c2243c8 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_login.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_logout.xml b/apk/android/echo/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..bb85973 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_mono.xml b/apk/android/echo/src/main/res/drawable/ic_mono.xml new file mode 100644 index 0000000..88e1d1c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_mono.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_more_horiz.xml b/apk/android/echo/src/main/res/drawable/ic_more_horiz.xml new file mode 100644 index 0000000..ed11170 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_more_horiz.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_music.xml b/apk/android/echo/src/main/res/drawable/ic_music.xml new file mode 100644 index 0000000..ca10634 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_numbers.xml b/apk/android/echo/src/main/res/drawable/ic_numbers.xml new file mode 100644 index 0000000..b67cc32 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_numbers.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_offline.xml b/apk/android/echo/src/main/res/drawable/ic_offline.xml new file mode 100644 index 0000000..f74dfc7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_offline.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_offline_files.xml b/apk/android/echo/src/main/res/drawable/ic_offline_files.xml new file mode 100644 index 0000000..27e9172 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_offline_files.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_palette.xml b/apk/android/echo/src/main/res/drawable/ic_palette.xml new file mode 100644 index 0000000..fda45bd --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_password.xml b/apk/android/echo/src/main/res/drawable/ic_password.xml new file mode 100644 index 0000000..671a3e7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_password.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_pause.xml b/apk/android/echo/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..a58a29a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_pause_48dp.xml b/apk/android/echo/src/main/res/drawable/ic_pause_48dp.xml new file mode 100644 index 0000000..9177d59 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_pause_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_pause_to_play_48dp_anim.xml b/apk/android/echo/src/main/res/drawable/ic_pause_to_play_48dp_anim.xml new file mode 100644 index 0000000..f8ccf6d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_pause_to_play_48dp_anim.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_pause_to_play_anim.xml b/apk/android/echo/src/main/res/drawable/ic_pause_to_play_anim.xml new file mode 100644 index 0000000..12f980d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_pause_to_play_anim.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_person.xml b/apk/android/echo/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..7627534 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_play.xml b/apk/android/echo/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..f09b3ad --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_play_48dp.xml b/apk/android/echo/src/main/res/drawable/ic_play_48dp.xml new file mode 100644 index 0000000..f794884 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_play_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_play_circle.xml b/apk/android/echo/src/main/res/drawable/ic_play_circle.xml new file mode 100644 index 0000000..bba28b8 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_play_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_play_to_pause_48dp_anim.xml b/apk/android/echo/src/main/res/drawable/ic_play_to_pause_48dp_anim.xml new file mode 100644 index 0000000..31a4c80 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_play_to_pause_48dp_anim.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_play_to_pause_anim.xml b/apk/android/echo/src/main/res/drawable/ic_play_to_pause_anim.xml new file mode 100644 index 0000000..20a7862 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_play_to_pause_anim.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_playlist_add.xml b/apk/android/echo/src/main/res/drawable/ic_playlist_add.xml new file mode 100644 index 0000000..6958335 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_playlist_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_playlist_play.xml b/apk/android/echo/src/main/res/drawable/ic_playlist_play.xml new file mode 100644 index 0000000..f91ed60 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_playlist_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_queue_music.xml b/apk/android/echo/src/main/res/drawable/ic_queue_music.xml new file mode 100644 index 0000000..c715afb --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_queue_music.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_refresh.xml b/apk/android/echo/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..998e5f1 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_20dp.xml new file mode 100644 index 0000000..b1d78af --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_40dp.xml new file mode 100644 index 0000000..e893492 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_40dp.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_off_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_off_40dp.xml new file mode 100644 index 0000000..86d657d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_off_40dp.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_off_to_repeat_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_off_to_repeat_40dp.xml new file mode 100644 index 0000000..3a5f1a4 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_off_to_repeat_40dp.xml @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_on_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_on_20dp.xml new file mode 100644 index 0000000..27f5d68 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_on_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_one_20dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_one_20dp.xml new file mode 100644 index 0000000..e67c274 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_one_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_one_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_one_40dp.xml new file mode 100644 index 0000000..05c2d11 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_one_40dp.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_one_to_repeat_off_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_one_to_repeat_off_40dp.xml new file mode 100644 index 0000000..7a1c1f5 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_one_to_repeat_off_40dp.xml @@ -0,0 +1,479 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_repeat_to_repeat_one_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_repeat_to_repeat_one_40dp.xml new file mode 100644 index 0000000..0653496 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_repeat_to_repeat_one_40dp.xml @@ -0,0 +1,450 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_save.xml b/apk/android/echo/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..e9a3e7c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_scan_delete.xml b/apk/android/echo/src/main/res/drawable/ic_scan_delete.xml new file mode 100644 index 0000000..67dcc8f --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_scan_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_schedule.xml b/apk/android/echo/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 0000000..24e1ce1 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_search_anim.xml b/apk/android/echo/src/main/res/drawable/ic_search_anim.xml new file mode 100644 index 0000000..ec62667 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_search_anim.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_search_filled.xml b/apk/android/echo/src/main/res/drawable/ic_search_filled.xml new file mode 100644 index 0000000..42c7504 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_search_filled.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_search_outline.xml b/apk/android/echo/src/main/res/drawable/ic_search_outline.xml new file mode 100644 index 0000000..4a01696 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_search_outline.xml @@ -0,0 +1,15 @@ + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_sensors.xml b/apk/android/echo/src/main/res/drawable/ic_sensors.xml new file mode 100644 index 0000000..c0d4e93 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_sensors.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_settings_outline.xml b/apk/android/echo/src/main/res/drawable/ic_settings_outline.xml new file mode 100644 index 0000000..c5bd14c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_settings_outline.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_settings_outline_32dp.xml b/apk/android/echo/src/main/res/drawable/ic_settings_outline_32dp.xml new file mode 100644 index 0000000..727d076 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_settings_outline_32dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ic_share.xml b/apk/android/echo/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..b73b761 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_shuffle.xml b/apk/android/echo/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 0000000..27b05f0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_shuffle_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_shuffle_40dp.xml new file mode 100644 index 0000000..9dbfb9d --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_shuffle_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_shuffle_on_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_shuffle_on_40dp.xml new file mode 100644 index 0000000..e43d52a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_shuffle_on_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_skip_next_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_skip_next_40dp.xml new file mode 100644 index 0000000..381f8d0 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_skip_next_40dp.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_skip_previous_40dp.xml b/apk/android/echo/src/main/res/drawable/ic_skip_previous_40dp.xml new file mode 100644 index 0000000..7d4f121 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_skip_previous_40dp.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_snooze.xml b/apk/android/echo/src/main/res/drawable/ic_snooze.xml new file mode 100644 index 0000000..08e34d8 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_snooze.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_sort.xml b/apk/android/echo/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..a60d001 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,10 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_telegram.xml b/apk/android/echo/src/main/res/drawable/ic_telegram.xml new file mode 100644 index 0000000..0cff7e1 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_telegram.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_translate.xml b/apk/android/echo/src/main/res/drawable/ic_translate.xml new file mode 100644 index 0000000..ecd343c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ic_unhide.xml b/apk/android/echo/src/main/res/drawable/ic_unhide.xml new file mode 100644 index 0000000..905ef1e --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ic_unhide.xml @@ -0,0 +1,9 @@ + + + diff --git a/apk/android/echo/src/main/res/drawable/ripple_44dp.xml b/apk/android/echo/src/main/res/drawable/ripple_44dp.xml new file mode 100644 index 0000000..2e4feed --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ripple_44dp.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ripple_60dp.xml b/apk/android/echo/src/main/res/drawable/ripple_60dp.xml new file mode 100644 index 0000000..a749ac7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ripple_60dp.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ripple_68dp.xml b/apk/android/echo/src/main/res/drawable/ripple_68dp.xml new file mode 100644 index 0000000..e2e30f7 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ripple_68dp.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/ripple_item_container.xml b/apk/android/echo/src/main/res/drawable/ripple_item_container.xml new file mode 100644 index 0000000..ef2e880 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/ripple_item_container.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/rounded_rectangle_24dp.xml b/apk/android/echo/src/main/res/drawable/rounded_rectangle_24dp.xml new file mode 100644 index 0000000..67b071c --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/rounded_rectangle_24dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover.xml b/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover.xml new file mode 100644 index 0000000..71aa3ee --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover_profile.xml b/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover_profile.xml new file mode 100644 index 0000000..ea0ea84 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/rounded_rectangle_cover_profile.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/shape_rounded_4dp.xml b/apk/android/echo/src/main/res/drawable/shape_rounded_4dp.xml new file mode 100644 index 0000000..f318526 --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/shape_rounded_4dp.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/shape_rounded_item_corner.xml b/apk/android/echo/src/main/res/drawable/shape_rounded_item_corner.xml new file mode 100644 index 0000000..6b0155e --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/shape_rounded_item_corner.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/drawable/tab_indicator.xml b/apk/android/echo/src/main/res/drawable/tab_indicator.xml new file mode 100644 index 0000000..d3cd49a --- /dev/null +++ b/apk/android/echo/src/main/res/drawable/tab_indicator.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/layout-land/activity_main.xml b/apk/android/echo/src/main/res/layout-land/activity_main.xml new file mode 100644 index 0000000..fee57a3 --- /dev/null +++ b/apk/android/echo/src/main/res/layout-land/activity_main.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/layout-land/fragment_player.xml b/apk/android/echo/src/main/res/layout-land/fragment_player.xml new file mode 100644 index 0000000..c201f4a --- /dev/null +++ b/apk/android/echo/src/main/res/layout-land/fragment_player.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/layout-land/item_player_track.xml b/apk/android/echo/src/main/res/layout-land/item_player_track.xml new file mode 100644 index 0000000..12cd3e4 --- /dev/null +++ b/apk/android/echo/src/main/res/layout-land/item_player_track.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/layout/activity_main.xml b/apk/android/echo/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..660994c --- /dev/null +++ b/apk/android/echo/src/main/res/layout/activity_main.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apk/android/echo/src/main/res/layout/dialog_color_picker.xml b/apk/android/echo/src/main/res/layout/dialog_color_picker.xml new file mode 100644 index 0000000..db585fc --- /dev/null +++ b/apk/android/echo/src/main/res/layout/dialog_color_picker.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + +