Skip to content

Instantly share code, notes, and snippets.

@Promptlab37
Last active April 24, 2026 12:32
Show Gist options
  • Select an option

  • Save Promptlab37/1e8d65a650c91719781d978ee06b1adc to your computer and use it in GitHub Desktop.

Select an option

Save Promptlab37/1e8d65a650c91719781d978ee06b1adc to your computer and use it in GitHub Desktop.
PromptLab Downloader - Full Rebuild Prompt for AI Agents

PromptLab Downloader — Complete Rebuild Prompt

📥 Jak stáhnout tento prompt

  1. Klikni na tlačítko "Raw" (vpravo nahoře nad textem)
  2. Ctrl+A (označ vše) → Ctrl+C (zkopíruj)
  3. Vlož do Claude Code / ChatGPT / Cursor → enter → hotová appka ☕

Nebo klikni "Download ZIP" (vpravo nahoře na stránce) pro stažení jako soubor.


Paste this entire document into Claude Code, Cursor, or any LLM agent. It will recreate the identical Android video downloader app (YouTube / Instagram / Facebook) with the exact same UI, palette, fallback strategies and file‑publish logic.

1. Agent instructions (read first)

You are an Android build agent. Your task: recreate the Kotlin Android app PromptLab Downloader v2.0 from the specification and verbatim source files below. Rules:

  1. Create every file verbatim. Do not "improve" code, do not rename identifiers, do not change colors or dimensions.
  2. Directory layout must match section 3 exactly. Use forward slashes.
  3. After all files are written, run a clean Gradle build. The only dependencies are the Maven Central coordinates listed in app/build.gradle.kts — do not add anything.
  4. Do not add tests, README, license headers or comments beyond what appears below.
  5. Language of all user‑facing strings is Czech — keep it that way.
  6. Target: minSdk = 26, compileSdk = 34, targetSdk = 34, applicationId = com.latarnik.videodownloader.
  7. Signing / root build.gradle.kts, settings.gradle.kts, gradle.properties and Gradle wrapper are standard Android Studio defaults — generate them if missing.

2. What the app does

A single‑activity Android app that downloads videos from YouTube, Instagram and Facebook into the public Downloads/ folder.

Key behaviors:

  • yt‑dlp engine via io.github.junkfood02.youtubedl-android 0.18.1 (library + ffmpeg). On first launch the app blocks on updateYoutubeDL(NIGHTLY) so it always runs the newest nightly binary.
  • YouTube fallback chain: 5 strategies with different player_client variants; format 18 (progressive mp4, single HTTP URL, no PO token, no DASH) is the nuclear fallback.
  • Instagram extraction chain: embed endpoint → post page (iPhone UA) → snapinsta.app → saveig.app. First hit wins.
  • Facebook extraction: manual redirect resolve for fb.watch / /share/ URLs, then yt‑dlp, then fdown.net fallback.
  • Publish to Downloads: on API 29+ uses MediaStore.Downloads with IS_PENDING pattern; on API 26–28 uses legacy public dir + FileProvider and O(1) renameTo.
  • Share intent handler: ACTION_SEND text/plain autofills the URL.
  • Work dir: getExternalFilesDir(null)/work — yt‑dlp writes there first, then we publish to Downloads.
  • UI single screen, no scrolling: header + 3 platform cards + main card filling the rest. Main card has URL input, paste/clear, quality chips (Best / 1080 / 720 / 480 / audio‑mp3), progress bar, status pill, download button, and a "Play" button that appears after success.
  • Aesthetic: "Acid Lab" — almost‑black background #06060C, lime→emerald accent #C6F833 → #10B981, dark text #0A1400 on the bright button. Top‑right radial lime aurora glow. No violet, no pink, no generic purple/cyan AI look.

3. File structure

VideoDownloaderAndroid/
├── app/
│   ├── build.gradle.kts
│   └── src/main/
│       ├── AndroidManifest.xml
│       ├── java/com/latarnik/videodownloader/
│       │   └── MainActivity.kt
│       └── res/
│           ├── drawable/
│           │   ├── chip_background.xml
│           │   ├── logo_badge.xml
│           │   ├── platform_card_bg.xml
│           │   ├── rounded_button.xml
│           │   ├── rounded_card.xml
│           │   ├── rounded_input.xml
│           │   ├── status_pill.xml
│           │   └── window_background.xml
│           ├── layout/
│           │   └── activity_main.xml
│           ├── values/
│           │   ├── colors.xml
│           │   └── themes.xml
│           └── xml/
│               └── file_paths.xml
└── (standard Gradle wrapper + root build.gradle.kts + settings.gradle.kts)

4. app/build.gradle.kts

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.latarnik.videodownloader"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.latarnik.videodownloader"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "2.0"

        ndk {
            // arm64-v8a pokryvá 99 % všech Android telefonů od 2017 (všechno s Android 8+).
            // Pro zbytek vygeneruj samostatné APK přes abi splits níže.
            abiFilters += listOf("arm64-v8a")
        }
    }

    // Vytvoří samostatné APK per ABI — pokud budeš potřebovat 32-bit nebo x86_64 pro
    // starší/emulátor, zakomentuj abiFilters výše, povol splits a dostaneš malé APK pro
    // každou architekturu zvlášť.
    splits {
        abi {
            isEnable = false  // zapni na true když budeš chtít multi-ABI release
            reset()
            include("arm64-v8a", "armeabi-v7a", "x86_64")
            isUniversalApk = false
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
        debug {
            isDebuggable = true
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

    packaging {
        jniLibs {
            useLegacyPackaging = true
        }
        resources {
            excludes += listOf(
                "META-INF/LICENSE*",
                "META-INF/NOTICE*",
                "META-INF/*.kotlin_module",
                "META-INF/DEPENDENCIES",
                "META-INF/AL2.0",
                "META-INF/LGPL2.1",
                "**/*.md",
                "**/*.txt",
                "kotlin/**"
            )
        }
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

    // yt-dlp for Android (junkfood02 fork - actively maintained, used by Seal & YTDLnis)
    implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
    implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
}

5. app/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:label="PL Downloader"
        android:supportsRtl="true"
        android:theme="@style/AppTheme"
        android:requestLegacyExternalStorage="true">

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:screenOrientation="portrait"
            android:windowSoftInputMode="adjustResize"
            tools:ignore="LockedOrientationActivity,DiscouragedApi">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.SEND" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="text/plain" />
            </intent-filter>
        </activity>
    </application>
</manifest>

6. res/xml/file_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="external_storage"
        path="." />
    <external-files-path
        name="external_files"
        path="." />
</paths>

7. res/values/colors.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="bg_dark">#06060C</color>
    <color name="bg_elevated">#0E0E18</color>
    <color name="bg_card">#12121E</color>
    <color name="bg_input">#0A0A14</color>
    <color name="border">#1F1F33</color>
    <color name="border_hot">#2E2E4A</color>

    <color name="text_primary">#F8F8FC</color>
    <color name="text_secondary">#9598B8</color>
    <color name="text_dim">#4E506B</color>

    <color name="accent">#C6F833</color>
    <color name="accent_2">#10B981</color>
    <color name="accent_3">#22D3EE</color>
    <color name="accent_dark">#A3E635</color>
    <color name="on_accent">#0A1400</color>

    <color name="success">#34D399</color>
    <color name="error">#FB7185</color>
    <color name="chip_bg">#0E0E18</color>
    <color name="chip_selected">#C6F833</color>

    <color name="aurora_violet">#1A0B2E</color>
    <color name="aurora_pink">#1E0A20</color>

    <color name="youtube">#FF2D55</color>
    <color name="instagram">#E1306C</color>
    <color name="facebook">#2D7FF9</color>

    <color name="white">#FFFFFF</color>
    <color name="transparent">#00000000</color>
</resources>

8. res/values/themes.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="AppTheme" parent="Theme.Material3.Dark.NoActionBar">
        <item name="colorPrimary">@color/accent</item>
        <item name="colorPrimaryDark">@color/accent_dark</item>
        <item name="colorAccent">@color/accent</item>
        <item name="android:windowBackground">@drawable/window_background</item>
        <item name="android:statusBarColor">@android:color/transparent</item>
        <item name="android:navigationBarColor">@color/bg_dark</item>
        <item name="android:windowLightStatusBar">false</item>
    </style>
</resources>

9. Drawables

res/drawable/window_background.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/bg_dark" />
        </shape>
    </item>
    <item
        android:height="420dp"
        android:gravity="top">
        <shape android:shape="rectangle">
            <gradient
                android:type="linear"
                android:angle="270"
                android:startColor="#0F1A08"
                android:centerColor="#0A1205"
                android:endColor="#06060C" />
        </shape>
    </item>
    <item
        android:width="340dp"
        android:height="340dp"
        android:gravity="top|end">
        <shape android:shape="oval">
            <gradient
                android:type="radial"
                android:gradientRadius="280dp"
                android:startColor="#33C6F833"
                android:endColor="#00C6F833" />
        </shape>
    </item>
</layer-list>

res/drawable/logo_badge.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <gradient
        android:type="linear"
        android:angle="135"
        android:startColor="@color/accent"
        android:endColor="@color/accent_2" />
    <corners android:radius="10dp" />
</shape>

res/drawable/rounded_button.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <gradient
                android:type="linear"
                android:angle="0"
                android:startColor="#A3E635"
                android:endColor="#059669" />
            <corners android:radius="20dp" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <gradient
                android:type="linear"
                android:angle="0"
                android:startColor="@color/accent"
                android:endColor="@color/accent_2" />
            <corners android:radius="20dp" />
        </shape>
    </item>
</selector>

res/drawable/rounded_card.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/bg_card" />
    <corners android:radius="24dp" />
    <stroke android:width="1dp" android:color="@color/border" />
</shape>

res/drawable/rounded_input.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/bg_input" />
    <corners android:radius="16dp" />
    <stroke android:width="1dp" android:color="@color/border_hot" />
</shape>

res/drawable/chip_background.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <solid android:color="@color/border_hot" />
            <corners android:radius="14dp" />
        </shape>
    </item>
    <item android:state_selected="true">
        <shape android:shape="rectangle">
            <solid android:color="@color/accent" />
            <corners android:radius="14dp" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/bg_elevated" />
            <stroke android:width="1dp" android:color="@color/border" />
            <corners android:radius="14dp" />
        </shape>
    </item>
</selector>

res/drawable/platform_card_bg.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true">
        <shape android:shape="rectangle">
            <solid android:color="@color/accent" />
            <corners android:radius="18dp" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/bg_elevated" />
            <corners android:radius="18dp" />
            <stroke android:width="1dp" android:color="@color/border" />
        </shape>
    </item>
</selector>

res/drawable/status_pill.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/bg_elevated" />
    <stroke android:width="1dp" android:color="@color/border" />
    <corners android:radius="12dp" />
</shape>

10. res/layout/activity_main.xml

The root is a vertical LinearLayout. The main card uses height=0 + weight=1 to fill remaining screen, with two weighted View spacers inside (one above the quality section, one below) so content distributes across the whole card height without scrolling.

All four <Button>s are androidx.appcompat.widget.AppCompatButton with android:backgroundTint="@null". This is mandatory — a plain <Button> under Material3 theme gets auto‑upgraded to MaterialButton, which tints the background with colorPrimary and hides custom drawables / button text.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingHorizontal="18dp"
    android:paddingTop="20dp"
    android:paddingBottom="14dp">

    <!-- Header row -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:gravity="center_vertical"
        android:layout_marginBottom="14dp">

        <View
            android:layout_width="32dp"
            android:layout_height="32dp"
            android:background="@drawable/logo_badge"
            android:layout_marginEnd="12dp" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:orientation="vertical">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="PromptLab"
                android:textSize="9sp"
                android:textColor="@color/text_dim"
                android:letterSpacing="0.2"
                android:textStyle="bold"
                android:textAllCaps="true"
                android:includeFontPadding="false" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Downloader"
                android:textSize="20sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:includeFontPadding="false" />
        </LinearLayout>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="v2.0"
            android:textSize="9sp"
            android:textColor="@color/text_dim"
            android:fontFamily="monospace"
            android:background="@drawable/status_pill"
            android:paddingHorizontal="8dp"
            android:paddingVertical="4dp" />
    </LinearLayout>

    <!-- Platform cards -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:layout_marginBottom="14dp">

        <LinearLayout
            android:id="@+id/btnYoutube"
            android:layout_width="0dp"
            android:layout_height="68dp"
            android:layout_weight="1"
            android:layout_marginEnd="6dp"
            android:orientation="vertical"
            android:gravity="center"
            android:background="@drawable/platform_card_bg"
            android:clickable="true"
            android:focusable="true">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="YouTube"
                android:textSize="13sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:includeFontPadding="false" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Videos · Shorts"
                android:textSize="9sp"
                android:textColor="@color/text_secondary"
                android:includeFontPadding="false" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/btnInstagram"
            android:layout_width="0dp"
            android:layout_height="68dp"
            android:layout_weight="1"
            android:layout_marginHorizontal="6dp"
            android:orientation="vertical"
            android:gravity="center"
            android:background="@drawable/platform_card_bg"
            android:clickable="true"
            android:focusable="true">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Instagram"
                android:textSize="13sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:includeFontPadding="false" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Reels · Stories"
                android:textSize="9sp"
                android:textColor="@color/text_secondary"
                android:includeFontPadding="false" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/btnFacebook"
            android:layout_width="0dp"
            android:layout_height="68dp"
            android:layout_weight="1"
            android:layout_marginStart="6dp"
            android:orientation="vertical"
            android:gravity="center"
            android:background="@drawable/platform_card_bg"
            android:clickable="true"
            android:focusable="true">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Facebook"
                android:textSize="13sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:includeFontPadding="false" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Reels · Watch"
                android:textSize="9sp"
                android:textColor="@color/text_secondary"
                android:includeFontPadding="false" />
        </LinearLayout>
    </LinearLayout>

    <!-- Main card fills remaining space -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:orientation="vertical"
        android:background="@drawable/rounded_card"
        android:padding="18dp">

        <!-- URL input -->
        <EditText
            android:id="@+id/urlInput"
            android:layout_width="match_parent"
            android:layout_height="52dp"
            android:background="@drawable/rounded_input"
            android:hint="https://…"
            android:textColorHint="@color/text_dim"
            android:textColor="@color/text_primary"
            android:textSize="13sp"
            android:paddingHorizontal="14dp"
            android:singleLine="true"
            android:inputType="textUri"
            android:imeOptions="actionDone"
            android:fontFamily="monospace"
            android:layout_marginBottom="6dp" />

        <!-- Paste + Clear -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:layout_marginBottom="12dp">

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/pasteBtn"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginEnd="5dp"
                android:minHeight="0dp"
                android:minWidth="0dp"
                android:padding="0dp"
                android:text="Vložit"
                android:textSize="12sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:textAllCaps="false"
                android:background="@drawable/chip_background"
                android:backgroundTint="@null"
                android:stateListAnimator="@null" />

            <androidx.appcompat.widget.AppCompatButton
                android:id="@+id/clearBtn"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="0.6"
                android:layout_marginStart="5dp"
                android:minHeight="0dp"
                android:minWidth="0dp"
                android:padding="0dp"
                android:text="Smazat"
                android:textSize="12sp"
                android:textColor="@color/text_primary"
                android:textStyle="bold"
                android:textAllCaps="false"
                android:background="@drawable/chip_background"
                android:backgroundTint="@null"
                android:stateListAnimator="@null" />
        </LinearLayout>

        <!-- Flex spacer 1 -->
        <View
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <!-- Row: Save info + Quality label -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:gravity="center_vertical"
            android:layout_marginBottom="6dp">
            <View
                android:layout_width="3dp"
                android:layout_height="11dp"
                android:background="@color/accent"
                android:layout_marginEnd="7dp" />
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Kvalita"
                android:textSize="10sp"
                android:textColor="@color/text_secondary"
                android:textStyle="bold"
                android:letterSpacing="0.2"
                android:textAllCaps="true" />
            <TextView
                android:id="@+id/savePathLabel"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="· Download/"
                android:textSize="10sp"
                android:textColor="@color/text_dim"
                android:fontFamily="monospace"
                android:layout_marginStart="8dp" />
        </LinearLayout>

        <!-- Quality chips row 1 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:layout_marginBottom="6dp">

            <TextView
                android:id="@+id/qualBest"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginEnd="5dp"
                android:text="Nejlepší"
                android:textSize="12sp"
                android:textStyle="bold"
                android:gravity="center"
                android:background="@drawable/chip_background"
                android:textColor="@color/white"
                android:clickable="true"
                android:focusable="true" />

            <TextView
                android:id="@+id/qual1080"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginHorizontal="5dp"
                android:text="1080p"
                android:textSize="12sp"
                android:textStyle="bold"
                android:gravity="center"
                android:background="@drawable/chip_background"
                android:textColor="@color/text_secondary"
                android:clickable="true"
                android:focusable="true" />

            <TextView
                android:id="@+id/qual720"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginStart="5dp"
                android:text="720p"
                android:textSize="12sp"
                android:textStyle="bold"
                android:gravity="center"
                android:background="@drawable/chip_background"
                android:textColor="@color/text_secondary"
                android:clickable="true"
                android:focusable="true" />
        </LinearLayout>

        <!-- Quality chips row 2 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:layout_marginBottom="14dp">

            <TextView
                android:id="@+id/qual480"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginEnd="5dp"
                android:text="480p"
                android:textSize="12sp"
                android:textStyle="bold"
                android:gravity="center"
                android:background="@drawable/chip_background"
                android:textColor="@color/text_secondary"
                android:clickable="true"
                android:focusable="true" />

            <TextView
                android:id="@+id/qualAudio"
                android:layout_width="0dp"
                android:layout_height="46dp"
                android:layout_weight="1"
                android:layout_marginStart="5dp"
                android:text="Jen audio (MP3)"
                android:textSize="12sp"
                android:textStyle="bold"
                android:gravity="center"
                android:background="@drawable/chip_background"
                android:textColor="@color/text_secondary"
                android:clickable="true"
                android:focusable="true" />
        </LinearLayout>

        <!-- Flex spacer 2 -->
        <View
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <!-- Progress -->
        <ProgressBar
            android:id="@+id/progressBar"
            style="@android:style/Widget.ProgressBar.Horizontal"
            android:layout_width="match_parent"
            android:layout_height="4dp"
            android:max="100"
            android:progress="0"
            android:progressTint="@color/accent"
            android:progressBackgroundTint="@color/border"
            android:layout_marginBottom="8dp" />

        <!-- Status pill -->
        <TextView
            android:id="@+id/statusLabel"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Připraveno"
            android:textSize="11sp"
            android:textColor="@color/text_secondary"
            android:background="@drawable/status_pill"
            android:paddingHorizontal="12dp"
            android:paddingVertical="8dp"
            android:maxLines="2"
            android:ellipsize="end"
            android:layout_marginBottom="10dp" />

        <!-- Download button -->
        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/downloadBtn"
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:minHeight="0dp"
            android:padding="0dp"
            android:text="Stáhnout"
            android:textSize="15sp"
            android:textColor="@color/on_accent"
            android:textStyle="bold"
            android:background="@drawable/rounded_button"
            android:backgroundTint="@null"
            android:letterSpacing="0.04"
            android:textAllCaps="false"
            android:elevation="6dp"
            android:stateListAnimator="@null" />

        <!-- Play / Open -->
        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/openBtn"
            android:layout_width="match_parent"
            android:layout_height="46dp"
            android:minHeight="0dp"
            android:padding="0dp"
            android:text="▶   Přehrát video"
            android:textSize="13sp"
            android:textColor="@color/white"
            android:textStyle="bold"
            android:textAllCaps="false"
            android:background="@drawable/rounded_button"
            android:backgroundTint="@null"
            android:stateListAnimator="@null"
            android:layout_marginTop="8dp"
            android:visibility="gone" />

    </LinearLayout>

</LinearLayout>

11. MainActivity.kt (verbatim, complete)

Package: com.latarnik.videodownloader. This is the whole file — copy as is, do not delete any method.

package com.latarnik.videodownloader

import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.text.Html
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.content.res.ColorStateList
import androidx.core.graphics.toColorInt
import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import com.yausername.youtubedl_android.YoutubeDL.UpdateChannel
import com.yausername.ffmpeg.FFmpeg
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.regex.Pattern

class MainActivity : AppCompatActivity() {

    private lateinit var urlInput: EditText
    private lateinit var downloadBtn: Button
    private lateinit var pasteBtn: Button
    private lateinit var clearBtn: Button
    private lateinit var progressBar: ProgressBar
    private lateinit var statusLabel: TextView
    private lateinit var savePathLabel: TextView
    private lateinit var openBtn: Button

    /** The video file most recently published to Downloads, for the "Přehrát video" button. */
    private var lastPlayable: PublishedFile? = null

    private data class PublishedFile(val uri: Uri, val displayName: String, val mimeType: String)

    companion object {
        private const val TAG = "NexusDL"
        private const val FILE_PROVIDER_SUFFIX = ".fileprovider"
        private const val FFMPEG_RECONNECT_ARGS =
            "ffmpeg_i:-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
    }

    private var currentPlatform: String? = null
    private var selectedQuality = "best"

    @Volatile private var isDownloading = false
    @Volatile private var ytDlpReady = false

    private val platformButtons = mutableMapOf<String, View>()
    private val qualityChips = mutableMapOf<String, TextView>()

    private val platformColors = mapOf(
        "youtube" to "#FF2D55",
        "instagram" to "#F97316",
        "facebook" to "#2D7FF9"
    )

    /**
     * App-private work directory on external storage. Always writable without
     * any runtime permission on all supported Android versions (API 26+).
     * yt-dlp and direct downloads write here first, then we publish the final
     * file(s) into the public Downloads collection via MediaStore.
     */
    private val workDir: File by lazy {
        (getExternalFilesDir(null) ?: filesDir).resolve("work").also { it.mkdirs() }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        savedInstanceState?.let {
            currentPlatform = it.getString("platform")
            selectedQuality = it.getString("quality", "best")
        }

        initViews()
        setupPlatformButtons()
        setupQualityChips()
        setupUrlWatcher()
        setupDownloadButton()
        setupPasteClearButtons()
        handleShareIntent()
        initYtDlp()

        currentPlatform?.let { selectPlatform(it) }
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString("platform", currentPlatform)
        outState.putString("quality", selectedQuality)
        outState.putString("url", urlInput.text.toString())
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        savedInstanceState.getString("url")?.let { urlInput.setText(it) }
    }

    private fun initViews() {
        urlInput = findViewById(R.id.urlInput)
        downloadBtn = findViewById(R.id.downloadBtn)
        progressBar = findViewById(R.id.progressBar)
        statusLabel = findViewById(R.id.statusLabel)
        savePathLabel = findViewById(R.id.savePathLabel)
        savePathLabel.text = "Download"
        pasteBtn = findViewById(R.id.pasteBtn)
        clearBtn = findViewById(R.id.clearBtn)
        openBtn = findViewById(R.id.openBtn)
        openBtn.setOnClickListener { openLastPlayable() }
    }

    /** Launches an external player for the most recently downloaded file. */
    private fun openLastPlayable() {
        val file = lastPlayable ?: return
        val intent = Intent(Intent.ACTION_VIEW).apply {
            setDataAndType(file.uri, file.mimeType)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        try {
            startActivity(Intent.createChooser(intent, "Přehrát v"))
        } catch (_: ActivityNotFoundException) {
            setStatus("Žádná aplikace nenalezena pro přehrání videa.", StatusType.ERROR)
        } catch (e: Exception) {
            setStatus("Nelze otevřít: ${e.message?.take(80)}", StatusType.ERROR)
        }
    }

    private fun initYtDlp() {
        setStatus("Inicializuji...", StatusType.NORMAL)
        downloadBtn.isEnabled = false
        downloadBtn.alpha = 0.5f

        lifecycleScope.launch(Dispatchers.IO) {
            val appContext = applicationContext

            try {
                YoutubeDL.getInstance().init(appContext)
            } catch (e: Exception) {
                val msg = e.message?.lowercase() ?: ""
                if ("already" !in msg && "exist" !in msg) {
                    withContext(Dispatchers.Main) {
                        setStatus("Init chyba: ${e.message?.take(100)}", StatusType.ERROR)
                    }
                    return@launch
                }
            }

            try {
                FFmpeg.getInstance().init(appContext)
            } catch (_: Exception) { }

            withContext(Dispatchers.Main) {
                setStatus("Stahuji yt-dlp (nightly, může trvat)...", StatusType.NORMAL)
            }
            var updateOk = false
            var updateErr: String? = null
            try {
                val updateStart = System.currentTimeMillis()
                YoutubeDL.getInstance().updateYoutubeDL(appContext, UpdateChannel.NIGHTLY)
                val elapsed = (System.currentTimeMillis() - updateStart) / 1000
                updateOk = true
                withContext(Dispatchers.Main) {
                    setStatus("yt-dlp aktualizován (${elapsed}s)", StatusType.NORMAL)
                }
            } catch (e: Exception) {
                updateErr = e.message?.take(120) ?: "unknown"
            }

            val version = try {
                YoutubeDL.getInstance().version(appContext) ?: "?"
            } catch (_: Exception) {
                "?"
            }

            ytDlpReady = true
            withContext(Dispatchers.Main) {
                val statusText = when {
                    updateOk -> "Připraveno • yt-dlp $version"
                    updateErr != null -> "Připraveno • yt-dlp $version (update fail: $updateErr)"
                    else -> "Připraveno • yt-dlp $version"
                }
                setStatus(statusText, if (updateOk) StatusType.SUCCESS else StatusType.NORMAL)
                downloadBtn.isEnabled = true
                downloadBtn.alpha = 1f
            }
        }
    }

    private fun setupPlatformButtons() {
        platformButtons["youtube"] = findViewById(R.id.btnYoutube)
        platformButtons["instagram"] = findViewById(R.id.btnInstagram)
        platformButtons["facebook"] = findViewById(R.id.btnFacebook)
        platformButtons.forEach { (key, view) ->
            view.setOnClickListener { selectPlatform(key) }
        }
    }

    private fun setupQualityChips() {
        qualityChips["best"] = findViewById(R.id.qualBest)
        qualityChips["1080"] = findViewById(R.id.qual1080)
        qualityChips["720"] = findViewById(R.id.qual720)
        qualityChips["480"] = findViewById(R.id.qual480)
        qualityChips["audio"] = findViewById(R.id.qualAudio)
        qualityChips.forEach { (value, chip) ->
            chip.setOnClickListener { selectQuality(value) }
        }
        updateQualityChipColors()
        applyDownloadButtonGradient()
    }

    private fun setupUrlWatcher() {
        urlInput.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            override fun afterTextChanged(s: Editable?) {
                val url = s?.toString()?.trim() ?: return
                val detected = detectPlatform(url)
                if (detected != null && detected != currentPlatform) {
                    selectPlatform(detected)
                }
            }
        })
    }

    private fun setupDownloadButton() {
        downloadBtn.setOnClickListener { startDownload() }
    }

    private fun setupPasteClearButtons() {
        pasteBtn.setOnClickListener {
            val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
            val clip = clipboard.primaryClip
            if (clip != null && clip.itemCount > 0) {
                val text = clip.getItemAt(0).text?.toString()?.trim() ?: ""
                if (text.isNotEmpty()) {
                    urlInput.setText(text)
                    urlInput.setSelection(text.length)
                }
            }
        }

        clearBtn.setOnClickListener {
            urlInput.setText("")
        }
    }

    private fun handleShareIntent() {
        if (intent?.action == Intent.ACTION_SEND) {
            intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text ->
                val urlPattern = Regex("https?://\\S+")
                val match = urlPattern.find(text)
                if (match != null) {
                    urlInput.setText(match.value)
                }
            }
        }
    }

    /**
     * Facebook share/short URLs (fb.watch, facebook.com/share/v/, etc.)
     * redirect to the actual video page. yt-dlp often fails on these,
     * so we resolve the redirect manually first.
     */
    private fun resolveFacebookUrl(url: String): String {
        val lower = url.lowercase()
        val needsResolve = "fb.watch" in lower
                || "fbwat.ch" in lower
                || "/share/v/" in lower
                || "/share/r/" in lower
                || "/share/p/" in lower

        if (!needsResolve) return url

        try {
            var currentUrl = url
            var attempt = 0
            while (attempt++ < 10) {
                val conn = URL(currentUrl).openConnection() as HttpURLConnection
                conn.instanceFollowRedirects = false
                conn.connectTimeout = 10_000
                conn.readTimeout = 10_000
                conn.setRequestProperty("User-Agent",
                    "Mozilla/5.0 (Linux; Android 14; SM-S936B) " +
                    "AppleWebKit/537.36 (KHTML, like Gecko) " +
                    "Chrome/124.0.0.0 Mobile Safari/537.36")
                conn.requestMethod = "GET"

                val code = conn.responseCode
                val location = conn.getHeaderField("Location")
                conn.disconnect()

                if (code in 301..303 || code == 307 || code == 308) {
                    if (location != null) {
                        currentUrl = if (location.startsWith("http")) {
                            location
                        } else {
                            URL(URL(currentUrl), location).toString()
                        }
                        val resolvedLower = currentUrl.lowercase()
                        if (("facebook.com" in resolvedLower || "fb.com" in resolvedLower)
                            && "/share/" !in resolvedLower
                            && "fb.watch" !in resolvedLower
                            && "login" !in resolvedLower) {
                            return currentUrl
                        }
                        continue
                    }
                }
                return currentUrl
            }
            return currentUrl
        } catch (_: Exception) {
            return url
        }
    }

    /**
     * Main Instagram extraction entry point. Tries multiple methods in order
     * until one succeeds. This is the same chain online downloaders use.
     */
    private fun extractInstagramVideo(igUrl: String): String? {
        val shortcodePattern = Pattern.compile(
            "instagram\\.com/(?:p|reel|reels|tv)/([^/?#&]+)"
        )
        val matcher = shortcodePattern.matcher(igUrl)
        if (!matcher.find()) return null
        val shortcode = matcher.group(1) ?: return null

        extractInstagramVideoViaEmbed(shortcode)?.let { return it }
        extractInstagramVideoViaPostPage(shortcode)?.let { return it }
        extractInstagramVideoViaSnapinsta(igUrl)?.let { return it }
        extractInstagramVideoViaSaveig(igUrl)?.let { return it }

        return null
    }

    private fun extractInstagramVideoViaEmbed(shortcode: String): String? {
        val endpoints = listOf(
            "https://www.instagram.com/p/$shortcode/embed/captioned/",
            "https://www.instagram.com/reel/$shortcode/embed/captioned/",
            "https://www.instagram.com/p/$shortcode/embed/",
            "https://www.instagram.com/reel/$shortcode/embed/"
        )
        for (endpoint in endpoints) {
            fetchInstagramEmbed(endpoint)?.let { return it }
        }
        return null
    }

    /**
     * Fetch the actual post page HTML and parse og:video meta tag or inline JSON.
     * Uses iPhone user agent to get the mobile-friendly simpler page.
     */
    private fun extractInstagramVideoViaPostPage(shortcode: String): String? {
        val urls = listOf(
            "https://www.instagram.com/reel/$shortcode/",
            "https://www.instagram.com/p/$shortcode/"
        )
        for (pageUrl in urls) {
            try {
                val conn = URL(pageUrl).openConnection() as HttpURLConnection
                conn.requestMethod = "GET"
                conn.instanceFollowRedirects = true
                conn.connectTimeout = 15_000
                conn.readTimeout = 15_000
                conn.setRequestProperty("User-Agent",
                    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " +
                    "AppleWebKit/605.1.15 (KHTML, like Gecko) " +
                    "Version/17.4 Mobile/15E148 Safari/604.1")
                conn.setRequestProperty("Accept",
                    "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
                conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9")

                if (conn.responseCode !in 200..299) {
                    conn.disconnect()
                    continue
                }

                val html = conn.inputStream.bufferedReader().readText()
                conn.disconnect()

                val ogVideoPattern = Pattern.compile(
                    "<meta[^>]+property=\"og:video(?::secure_url)?\"[^>]+content=\"([^\"]+)\""
                )
                val ogm = ogVideoPattern.matcher(html)
                if (ogm.find()) {
                    val url = decodeHtmlEntities(ogm.group(1)!!)
                    if (url.contains(".mp4")) return url
                }

                val videoUrlPattern = Pattern.compile("\"video_url\"\\s*:\\s*\"([^\"]+)\"")
                val vm = videoUrlPattern.matcher(html)
                if (vm.find()) {
                    return unescapeJsonUrl(vm.group(1)!!)
                }

                val mp4Pattern = Pattern.compile(
                    "(https:[^\"'\\s]*?(?:cdninstagram|fbcdn)[^\"'\\s]*?\\.mp4[^\"'\\s]*)"
                )
                val mm = mp4Pattern.matcher(html)
                if (mm.find()) {
                    return unescapeJsonUrl(mm.group(1)!!)
                }
            } catch (_: Exception) {
            }
        }
        return null
    }

    /**
     * snapinsta.app — a popular third-party IG downloader used under the hood
     * by many online tools. POST form-encoded with the IG URL.
     */
    private fun extractInstagramVideoViaSnapinsta(igUrl: String): String? {
        return try {
            val endpoints = listOf(
                "https://snapinsta.app/action2.php",
                "https://snapinsta.app/action.php"
            )
            for (endpoint in endpoints) {
                val result = postFormAndExtract(
                    endpoint,
                    "url=${URLEncoder.encode(igUrl, "UTF-8")}&action=post&lang=en",
                    referer = "https://snapinsta.app/"
                )
                if (result != null) return result
            }
            null
        } catch (_: Exception) {
            null
        }
    }

    /**
     * saveig.app — another third-party IG downloader API.
     */
    private fun extractInstagramVideoViaSaveig(igUrl: String): String? {
        return try {
            val result = postFormAndExtract(
                "https://v3.saveig.app/api/ajaxSearch",
                "q=${URLEncoder.encode(igUrl, "UTF-8")}&t=media&lang=en",
                referer = "https://saveig.app/"
            )
            result
        } catch (_: Exception) {
            null
        }
    }

    /**
     * POST form data to a third-party downloader API and try to extract
     * the direct video URL from the response (HTML or JSON).
     */
    private fun postFormAndExtract(endpoint: String, body: String, referer: String): String? {
        try {
            val conn = URL(endpoint).openConnection() as HttpURLConnection
            conn.requestMethod = "POST"
            conn.doOutput = true
            conn.instanceFollowRedirects = true
            conn.connectTimeout = 15_000
            conn.readTimeout = 20_000
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
            conn.setRequestProperty("User-Agent",
                "Mozilla/5.0 (Linux; Android 14; SM-S936B) AppleWebKit/537.36 " +
                "(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36")
            conn.setRequestProperty("Accept", "*/*")
            conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9")
            conn.setRequestProperty("X-Requested-With", "XMLHttpRequest")
            conn.setRequestProperty("Referer", referer)
            conn.setRequestProperty("Origin", referer.trimEnd('/'))

            conn.outputStream.use { it.write(body.toByteArray()) }

            if (conn.responseCode !in 200..299) {
                conn.disconnect()
                return null
            }

            val response = conn.inputStream.bufferedReader().readText()
            conn.disconnect()

            val jsonUrlPattern = Pattern.compile("\"url\"\\s*:\\s*\"([^\"]+\\.mp4[^\"]*)\"")
            val jm = jsonUrlPattern.matcher(response)
            if (jm.find()) return unescapeJsonUrl(jm.group(1)!!)

            val downloadHrefPattern = Pattern.compile(
                "href=\"(https:[^\"]+\\.mp4[^\"]*)\"[^>]*download"
            )
            val dm = downloadHrefPattern.matcher(response)
            if (dm.find()) return decodeHtmlEntities(dm.group(1)!!)

            val mp4Pattern = Pattern.compile(
                "(https:[^\"'\\s\\\\]*?\\.mp4[^\"'\\s\\\\]*)"
            )
            val mm = mp4Pattern.matcher(response)
            while (mm.find()) {
                val url = unescapeJsonUrl(mm.group(1)!!)
                if (url.contains("cdninstagram") || url.contains("fbcdn")) {
                    return url
                }
            }
            mm.reset()
            if (mm.find()) return unescapeJsonUrl(mm.group(1)!!)

            return null
        } catch (_: Exception) {
            return null
        }
    }

    private fun fetchInstagramEmbed(endpoint: String): String? {
        try {
            val conn = URL(endpoint).openConnection() as HttpURLConnection
            conn.requestMethod = "GET"
            conn.instanceFollowRedirects = true
            conn.connectTimeout = 15_000
            conn.readTimeout = 15_000
            conn.setRequestProperty("User-Agent",
                "Mozilla/5.0 (Linux; Android 14; SM-S936B) " +
                "AppleWebKit/537.36 (KHTML, like Gecko) " +
                "Chrome/124.0.0.0 Mobile Safari/537.36")
            conn.setRequestProperty("Accept",
                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
            conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9")
            conn.setRequestProperty("Sec-Fetch-Dest", "document")
            conn.setRequestProperty("Sec-Fetch-Mode", "navigate")
            conn.setRequestProperty("Sec-Fetch-Site", "none")

            if (conn.responseCode !in 200..299) {
                conn.disconnect()
                return null
            }

            val html = conn.inputStream.bufferedReader().readText()
            conn.disconnect()

            val videoUrlPattern = Pattern.compile("\"video_url\"\\s*:\\s*\"([^\"]+)\"")
            val vm = videoUrlPattern.matcher(html)
            if (vm.find()) {
                return unescapeJsonUrl(vm.group(1)!!)
            }

            val versionsPattern = Pattern.compile(
                "\"video_versions\"\\s*:\\s*\\[\\s*\\{[^}]*?\"url\"\\s*:\\s*\"([^\"]+)\""
            )
            val vvm = versionsPattern.matcher(html)
            if (vvm.find()) {
                return unescapeJsonUrl(vvm.group(1)!!)
            }

            val mp4Pattern = Pattern.compile(
                "(https:[^\"'\\s]*?(?:cdninstagram|fbcdn)[^\"'\\s]*?\\.mp4[^\"'\\s]*)"
            )
            val mm = mp4Pattern.matcher(html)
            if (mm.find()) {
                return unescapeJsonUrl(mm.group(1)!!)
            }

            return null
        } catch (_: Exception) {
            return null
        }
    }

    private fun unescapeJsonUrl(s: String): String {
        return s.replace("\\/", "/")
            .replace("\\u0026", "&")
            .replace("\\u003d", "=")
            .replace("\\u003D", "=")
            .replace("\\u0025", "%")
            .replace("\\\"", "\"")
            .replace("\\\\", "\\")
    }

    /**
     * Fallback for Facebook: use fdown.net to extract direct video URL.
     */
    private fun extractFacebookVideoViaFdown(fbUrl: String): String? {
        try {
            val conn = URL("https://fdown.net/download.php").openConnection() as HttpURLConnection
            conn.requestMethod = "POST"
            conn.doOutput = true
            conn.connectTimeout = 15_000
            conn.readTimeout = 15_000
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
            conn.setRequestProperty("User-Agent",
                "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/124.0.0.0 Mobile Safari/537.36")
            conn.setRequestProperty("Referer", "https://fdown.net/")
            conn.setRequestProperty("Origin", "https://fdown.net")

            val body = "URLz=${URLEncoder.encode(fbUrl, "UTF-8")}"
            conn.outputStream.use { it.write(body.toByteArray()) }

            val html = conn.inputStream.bufferedReader().readText()
            conn.disconnect()

            val hdPattern = Pattern.compile("id=\"btn_download_hd\"[^>]*href=\"([^\"]+)\"")
            val sdPattern = Pattern.compile("id=\"btn_download_sd\"[^>]*href=\"([^\"]+)\"")
            val btnPattern = Pattern.compile("btn btn-primary[^\"]*\"[^>]*href=\"(https://[^\"]+)\"")

            val hdMatcher = hdPattern.matcher(html)
            if (hdMatcher.find()) return decodeHtmlEntities(hdMatcher.group(1)!!)

            val sdMatcher = sdPattern.matcher(html)
            if (sdMatcher.find()) return decodeHtmlEntities(sdMatcher.group(1)!!)

            val linkPattern = Pattern.compile("href=\"(https://video[^\"]+fbcdn\\.net[^\"]+)\"")
            val linkMatcher = linkPattern.matcher(html)
            if (linkMatcher.find()) return decodeHtmlEntities(linkMatcher.group(1)!!)

            val btnMatcher = btnPattern.matcher(html)
            if (btnMatcher.find()) return decodeHtmlEntities(btnMatcher.group(1)!!)

            return null
        } catch (_: Exception) {
            return null
        }
    }

    private fun downloadFileDirectly(videoUrl: String, onProgress: (Int) -> Unit): File {
        val timestamp = System.currentTimeMillis()
        return downloadFileDirectlyNamed(videoUrl, "facebook_video_$timestamp.mp4", onProgress)
    }

    private fun downloadFileDirectlyNamed(
        videoUrl: String,
        fileName: String,
        onProgress: (Int) -> Unit
    ): File {
        val conn = URL(videoUrl).openConnection() as HttpURLConnection
        conn.instanceFollowRedirects = true
        conn.connectTimeout = 15_000
        conn.readTimeout = 30_000
        conn.setRequestProperty("User-Agent",
            "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/124.0.0.0 Mobile Safari/537.36")
        conn.setRequestProperty("Referer", "https://www.instagram.com/")

        val totalSize = conn.contentLength.toLong()
        val outFile = File(workDir, fileName)

        BufferedInputStream(conn.inputStream).use { input ->
            FileOutputStream(outFile).use { output ->
                val buffer = ByteArray(8192)
                var downloaded = 0L
                var bytesRead: Int
                while (input.read(buffer).also { bytesRead = it } != -1) {
                    output.write(buffer, 0, bytesRead)
                    downloaded += bytesRead
                    if (totalSize > 0) {
                        val pct = (downloaded * 100 / totalSize).toInt().coerceIn(0, 100)
                        onProgress(pct)
                    }
                }
            }
        }
        conn.disconnect()
        return outFile
    }

    private fun decodeHtmlEntities(text: String): String {
        return Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY).toString()
    }

    private fun isOnline(): Boolean {
        val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork ?: return false
        val caps = cm.getNetworkCapabilities(network) ?: return false
        return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }

    private fun detectPlatform(url: String): String? {
        val lower = url.lowercase()
        return when {
            "youtube.com" in lower || "youtu.be" in lower -> "youtube"
            "instagram.com" in lower || "instagr.am" in lower -> "instagram"
            "facebook.com" in lower || "fb.watch" in lower || "fb.com" in lower
                || "m.facebook.com" in lower || "web.facebook.com" in lower
                || "fbwat.ch" in lower -> "facebook"
            else -> null
        }
    }

    private fun selectPlatform(key: String) {
        currentPlatform = key
        val color = (platformColors[key] ?: "#C6F833").toColorInt()
        val dp = resources.displayMetrics.density

        platformButtons.forEach { (k, view) ->
            val bg = GradientDrawable()
            bg.cornerRadius = 18f * dp
            if (k == key) {
                bg.colors = intArrayOf(brighten(color), color, darken(color))
                bg.orientation = GradientDrawable.Orientation.TL_BR
            } else {
                bg.setColor("#0E0E18".toColorInt())
                bg.setStroke((1 * dp).toInt(), "#1F1F33".toColorInt())
            }
            view.background = bg
        }

        progressBar.progressTintList = ColorStateList.valueOf(color)
        updateQualityChipColors()
    }

    private fun selectQuality(value: String) {
        selectedQuality = value
        updateQualityChipColors()
    }

    private fun updateQualityChipColors() {
        val dp = resources.displayMetrics.density
        val accentColor = "#C6F833".toColorInt()
        val accentColor2 = "#10B981".toColorInt()
        val onAccent = "#0A1400".toColorInt()
        val chipBg = "#0E0E18".toColorInt()
        val chipBorder = "#1F1F33".toColorInt()
        val chipText = "#9598B8".toColorInt()
        qualityChips.forEach { (value, chip) ->
            val bg = GradientDrawable()
            bg.cornerRadius = 14f * dp
            if (value == selectedQuality) {
                bg.colors = intArrayOf(accentColor, accentColor2)
                bg.orientation = GradientDrawable.Orientation.LEFT_RIGHT
                chip.setTextColor(onAccent)
            } else {
                bg.setColor(chipBg)
                bg.setStroke((1 * dp).toInt(), chipBorder)
                chip.setTextColor(chipText)
            }
            chip.background = bg
        }
    }

    private fun brighten(color: Int): Int {
        val r = (Color.red(color) + (255 - Color.red(color)) * 0.15f).toInt().coerceIn(0, 255)
        val g = (Color.green(color) + (255 - Color.green(color)) * 0.15f).toInt().coerceIn(0, 255)
        val b = (Color.blue(color) + (255 - Color.blue(color)) * 0.15f).toInt().coerceIn(0, 255)
        return Color.rgb(r, g, b)
    }

    private fun darken(color: Int): Int {
        val r = (Color.red(color) * 0.85f).toInt().coerceIn(0, 255)
        val g = (Color.green(color) * 0.85f).toInt().coerceIn(0, 255)
        val b = (Color.blue(color) * 0.85f).toInt().coerceIn(0, 255)
        return Color.rgb(r, g, b)
    }

    private fun startDownload() {
        val url = urlInput.text.toString().trim()
        if (url.isEmpty()) {
            Toast.makeText(this, "Zadej URL videa", Toast.LENGTH_SHORT).show()
            return
        }
        if (isDownloading) return
        if (!ytDlpReady) {
            Toast.makeText(this, "Počkej, yt-dlp se stále připravuje...", Toast.LENGTH_LONG).show()
            return
        }
        if (!isOnline()) {
            setStatus("Nemáš připojení k internetu.", StatusType.ERROR)
            return
        }

        isDownloading = true
        downloadBtn.isEnabled = false
        downloadBtn.alpha = 0.5f
        progressBar.progress = 0
        openBtn.visibility = View.GONE
        lastPlayable = null
        setStatus("Připojuji se...", StatusType.NORMAL)

        lifecycleScope.launch(Dispatchers.IO) { download(url) }
    }

    /** Builds a YoutubeDLRequest with all base options shared across primary + retry attempts. */
    private fun buildBaseRequest(targetUrl: String): YoutubeDLRequest {
        return YoutubeDLRequest(targetUrl).apply {
            addOption("-o", "${workDir.absolutePath}/%(title).80s.%(ext)s")
            addOption("--paths", "temp:${workDir.absolutePath}")
            addOption("--no-mtime")
            addOption("--no-check-certificates")
            addOption("--force-ipv4")
            addOption("--extractor-retries", "5")
            addOption("--retries", "10")
            addOption("--fragment-retries", "10")
            addOption("--socket-timeout", "30")
            addOption("--downloader-args", FFMPEG_RECONNECT_ARGS)
        }
    }

    /** Adds the quality/format flags based on the user's selection. */
    private fun YoutubeDLRequest.applyQuality(format: String? = null) {
        if (format != null) {
            addOption("-f", format)
        } else when (selectedQuality) {
            "audio" -> addOption("-f", "bestaudio[ext=m4a]/bestaudio/best")
            "1080" -> addOption("-f", "bv*[height<=1080]+ba/b[height<=1080]/b")
            "720" -> addOption("-f", "bv*[height<=720]+ba/b[height<=720]/b")
            "480" -> addOption("-f", "bv*[height<=480]+ba/b[height<=480]/b")
            else -> addOption("-f", "bv*+ba/b")
        }
        if (selectedQuality == "audio") {
            addOption("-x")
            addOption("--audio-format", "mp3")
            addOption("--audio-quality", "192K")
        } else {
            addOption("--merge-output-format", "mp4")
        }
    }

    /** Returns a throttled yt-dlp progress callback that updates the UI at most every 300ms. */
    private fun makeProgressCallback(labelPrefix: String): (Float, Long, String) -> Unit {
        var lastUpdateMs = 0L
        return { progress, _, _ ->
            val now = System.currentTimeMillis()
            if (now - lastUpdateMs > 300) {
                lastUpdateMs = now
                lifecycleScope.launch(Dispatchers.Main) {
                    val pct = progress.toInt().coerceIn(0, 100)
                    progressBar.progress = pct
                    setStatus("$labelPrefix... ${pct}%", StatusType.NORMAL)
                }
            }
        }
    }

    private suspend fun download(url: String) {
        cleanWorkDir()

        val platform = detectPlatform(url)

        val resolvedUrl = if (platform == "facebook") {
            withContext(Dispatchers.Main) {
                setStatus("Zpracovávám Facebook odkaz...", StatusType.NORMAL)
            }
            resolveFacebookUrl(url)
        } else {
            url
        }

        val request = buildBaseRequest(resolvedUrl)

        if (platform == "youtube") {
            request.addOption(
                "--extractor-args",
                "youtube:player_client=default,-android_sdkless;formats=missing_pot"
            )
        } else {
            request.addOption(
                "--user-agent",
                "Mozilla/5.0 (Linux; Android 14; SM-S936B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
            )
        }

        if (platform == "facebook") {
            request.addOption("--add-header", "Accept:text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
            request.addOption("--add-header", "Accept-Language:en-US,en;q=0.5")
            request.addOption("--add-header", "Sec-Fetch-Mode:navigate")
            request.addOption("--add-header", "Sec-Fetch-Site:none")
        }

        request.applyQuality()

        try {
            try {
                YoutubeDL.getInstance().execute(request, null, makeProgressCallback("Stahuji"))
            } catch (firstErr: Exception) {
                val firstMsg = (firstErr.message ?: "").lowercase()
                val isExtractFail = "unsupported" in firstMsg || "no video" in firstMsg
                    || "unable to extract" in firstMsg || "cannot parse" in firstMsg
                    || "login" in firstMsg || "cookie" in firstMsg
                    || "rate" in firstMsg || "requested content" in firstMsg

                // YT fallback chain: format 18 (progressive mp4, single HTTP URL) is the
                // nuclear option because it needs no PO token and no DASH fragments.
                if (platform == "youtube") {
                    withContext(Dispatchers.Main) {
                        setStatus("YT zablokoval kvalitu, zkouším fallback...", StatusType.NORMAL)
                    }
                    cleanWorkDir()

                    val ytFallbackStrategies = listOf(
                        "youtube:player_client=ios;formats=missing_pot" to "22/18/b[protocol^=http]",
                        "youtube:player_client=tv;formats=missing_pot" to "22/18/b[protocol^=http]",
                        "youtube:player_client=mweb;formats=missing_pot" to "18",
                        "youtube:player_client=android_vr,tv_embedded;formats=missing_pot" to "18",
                        "youtube:player_client=default,-android_sdkless;formats=missing_pot"
                            to "b[protocol^=http][acodec!=none][vcodec!=none]/18/17"
                    )

                    var ytSuccess = false
                    var lastYtErr: Exception? = null
                    for ((idx, strategy) in ytFallbackStrategies.withIndex()) {
                        val (args, format) = strategy
                        withContext(Dispatchers.Main) {
                            setStatus("YT fallback ${idx + 1}/${ytFallbackStrategies.size}...", StatusType.NORMAL)
                        }
                        cleanWorkDir()
                        try {
                            val retryReq = buildBaseRequest(url).apply {
                                addOption("--extractor-args", args)
                                applyQuality(format)
                            }
                            YoutubeDL.getInstance().execute(
                                retryReq, null,
                                makeProgressCallback("Stahuji (fallback ${idx + 1})")
                            )
                            ytSuccess = true
                            break
                        } catch (retryErr: Exception) {
                            Log.w(TAG, "YT fallback ${idx + 1} failed: ${retryErr.message}")
                            lastYtErr = retryErr
                            cleanWorkDir()
                        }
                    }

                    if (!ytSuccess) {
                        throw lastYtErr ?: firstErr
                    }
                }
                else if (platform == "instagram" && isExtractFail) {
                    withContext(Dispatchers.Main) {
                        setStatus("Zkouším alternativní metodu...", StatusType.NORMAL)
                    }

                    val directUrl = extractInstagramVideo(url)
                        ?: throw Exception("Instagram video se nepodařilo stáhnout. Možná je soukromé nebo smazané.")

                    withContext(Dispatchers.Main) {
                        setStatus("Stahuji video...", StatusType.NORMAL)
                    }

                    val timestamp = System.currentTimeMillis()
                    var lastIgUpdateMs = 0L
                    downloadFileDirectlyNamed(directUrl, "instagram_video_$timestamp.mp4") { pct ->
                        val now = System.currentTimeMillis()
                        if (now - lastIgUpdateMs > 300) {
                            lastIgUpdateMs = now
                            lifecycleScope.launch(Dispatchers.Main) {
                                progressBar.progress = pct
                                setStatus("Stahuji... ${pct}%", StatusType.NORMAL)
                            }
                        }
                    }
                }
                else if (platform == "facebook" && isExtractFail) {
                    withContext(Dispatchers.Main) {
                        setStatus("Zkouším alternativní metodu...", StatusType.NORMAL)
                    }

                    val directUrl = extractFacebookVideoViaFdown(url)
                        ?: extractFacebookVideoViaFdown(resolvedUrl)
                        ?: throw Exception("Facebook video se nepodařilo stáhnout ani alternativní metodou.")

                    withContext(Dispatchers.Main) {
                        setStatus("Stahuji video...", StatusType.NORMAL)
                    }

                    var lastFbUpdateMs = 0L
                    downloadFileDirectly(directUrl) { pct ->
                        val now = System.currentTimeMillis()
                        if (now - lastFbUpdateMs > 300) {
                            lastFbUpdateMs = now
                            lifecycleScope.launch(Dispatchers.Main) {
                                progressBar.progress = pct
                                setStatus("Stahuji... ${pct}%", StatusType.NORMAL)
                            }
                        }
                    }
                } else {
                    throw firstErr
                }
            }

            val published = publishWorkDirFiles()
            if (published.isEmpty()) {
                throw Exception("Stahování proběhlo, ale nenašel jsem žádný výstupní soubor.")
            }

            val primary = published.first()
            withContext(Dispatchers.Main) {
                progressBar.progress = 100
                val label = if (published.size == 1) {
                    "Hotovo! ${primary.displayName}"
                } else {
                    "Hotovo! ${published.size} souborů v Download"
                }
                setStatus(label, StatusType.SUCCESS)
                lastPlayable = primary
                openBtn.visibility = View.VISIBLE
            }
        } catch (e: Exception) {
            cleanWorkDir()
            val fullErr = e.message ?: "Neznámá chyba"
            val lastLine = fullErr.lines()
                .map { it.trim() }
                .lastOrNull { it.startsWith("ERROR:") }
                ?.removePrefix("ERROR:")?.trim()

            val err = (lastLine ?: fullErr).lowercase()
            val displayErr = lastLine ?: fullErr.take(200)

            val msg = when {
                "unsupported url" in err ->
                    if (platform == "facebook")
                        "Facebook odkaz není podporován. Zkus zkopírovat přímo odkaz na video (ne sdílený odkaz)."
                    else "Nepodporovaný odkaz."
                "not found" in err || "404" in err ->
                    "Video nebylo nalezeno."
                "unable to extract" in err || "cannot parse" in err || "no video" in err ->
                    if (platform == "facebook")
                        "Facebook video se nepodařilo stáhnout. Zkus otevřít video v prohlížeči, zkopírovat URL z adresního řádku a vložit sem."
                    else "Nepodařilo se zpracovat: $displayErr"
                "login" in err || "cookie" in err || "private" in err ->
                    "Video vyžaduje přihlášení."
                else -> "Chyba: $displayErr"
            }
            withContext(Dispatchers.Main) {
                progressBar.progress = 0
                setStatus(msg, StatusType.ERROR)
            }
        } finally {
            withContext(Dispatchers.Main) {
                isDownloading = false
                downloadBtn.isEnabled = true
                downloadBtn.alpha = 1f
                restoreButtonColor()
            }
        }
    }

    private fun restoreButtonColor() {
        applyDownloadButtonGradient()
    }

    private fun applyDownloadButtonGradient() {
        val dp = resources.displayMetrics.density
        val btnBg = GradientDrawable(
            GradientDrawable.Orientation.LEFT_RIGHT,
            intArrayOf("#C6F833".toColorInt(), "#10B981".toColorInt())
        )
        btnBg.cornerRadius = 20f * dp
        downloadBtn.background = btnBg
    }

    /**
     * Publish a file from workDir into the public Downloads collection so it is
     * visible in Files / Downloads / Gallery apps. Uses MediaStore on API 29+
     * (Scoped Storage) and legacy public directory + FileProvider on API 26-28.
     */
    private fun publishToDownloads(src: File, displayName: String, mimeType: String): PublishedFile {
        if (!src.exists() || src.length() == 0L) {
            throw Exception("Stažený soubor je prázdný nebo chybí.")
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val resolver = contentResolver
            val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
            val values = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, displayName)
                put(MediaStore.Downloads.MIME_TYPE, mimeType)
                put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
                put(MediaStore.Downloads.IS_PENDING, 1)
            }
            val uri: Uri = resolver.insert(collection, values)
                ?: throw Exception("Nepodařilo se vytvořit záznam v Downloads.")
            try {
                resolver.openOutputStream(uri)?.use { out ->
                    src.inputStream().use { input -> input.copyTo(out) }
                } ?: throw Exception("Nelze otevřít cíl pro zápis.")
                values.clear()
                values.put(MediaStore.Downloads.IS_PENDING, 0)
                resolver.update(uri, values, null, null)
            } catch (e: Exception) {
                resolver.delete(uri, null, null)
                throw e
            }
            src.delete()
            return PublishedFile(uri, displayName, mimeType)
        } else {
            val publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
            publicDir.mkdirs()
            val dest = File(publicDir, displayName)
            if (dest.exists()) dest.delete()
            // workDir and public Downloads live on the same external volume, so rename is
            // an O(1) metadata move. Fall back to full copy only on cross-volume edge cases.
            if (!src.renameTo(dest)) {
                src.inputStream().use { input ->
                    FileOutputStream(dest).use { output -> input.copyTo(output) }
                }
                src.delete()
            }
            MediaScannerConnection.scanFile(
                applicationContext,
                arrayOf(dest.absolutePath),
                arrayOf(mimeType),
                null
            )
            val authority = applicationContext.packageName + FILE_PROVIDER_SUFFIX
            val uri = FileProvider.getUriForFile(this, authority, dest)
            return PublishedFile(uri, displayName, mimeType)
        }
    }

    private fun guessMimeType(fileName: String): String {
        val ext = File(fileName).extension.lowercase()
        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
            ?: "application/octet-stream"
    }

    /**
     * Publish every file currently in workDir to public Downloads and clear workDir.
     * Returns the list of published files (uri + name + mime). The first entry is the
     * "primary" file the user will want to open (first video, or audio if no video).
     */
    private fun publishWorkDirFiles(): List<PublishedFile> {
        val files = workDir.listFiles()?.filter { it.isFile && it.length() > 0 } ?: return emptyList()
        val sorted = files.sortedBy { f ->
            val mime = guessMimeType(f.name)
            when {
                mime.startsWith("video/") -> 0
                mime.startsWith("audio/") -> 1
                else -> 2
            }
        }
        val published = mutableListOf<PublishedFile>()
        for (f in sorted) {
            try {
                published += publishToDownloads(f, f.name, guessMimeType(f.name))
            } catch (e: Exception) {
                f.delete()
                throw e
            }
        }
        return published
    }

    private fun cleanWorkDir() {
        workDir.listFiles()?.forEach { it.delete() }
    }

    private enum class StatusType { NORMAL, SUCCESS, ERROR }

    private fun setStatus(text: String, type: StatusType) {
        statusLabel.text = text
        statusLabel.setTextColor(
            when (type) {
                StatusType.NORMAL -> "#9598B8".toColorInt()
                StatusType.SUCCESS -> "#34D399".toColorInt()
                StatusType.ERROR -> "#FB7185".toColorInt()
            }
        )
    }
}

12. Gotchas the agent MUST not "fix"

These all look like bugs or warnings but are load‑bearing — leave them alone:

  1. AppCompatButton with backgroundTint="@null" — mandatory. Plain <Button> under Theme.Material3.Dark.NoActionBar becomes a MaterialButton that overrides any custom background with colorPrimary. You will see the "Clear" button text disappear if you revert this.
  2. tools:ignore="LockedOrientationActivity,DiscouragedApi" — Android 16 lint wants fullSensor/unspecified orientation; we intentionally keep portrait lock for a single‑screen layout.
  3. requestLegacyExternalStorage="true" + legacy permissions gated by maxSdkVersion — needed for the pre‑Q FileProvider/renameTo branch.
  4. --force-ipv4 — yt‑dlp on Android has repeated IPv6 stall issues on mobile networks; force v4.
  5. YT format 18 — progressive 360p mp4. Looks low quality but it's the only format that survives YouTube PO‑token blocks. The fallback chain tries 22 (720p progressive) first, then falls to 18.
  6. UpdateChannel.NIGHTLY on every launch — YouTube breaks the stable yt‑dlp binary weekly; nightly keeps the app alive. Accept the ~5–30s first‑run init.
  7. suspend fun download(url) calls IO‑bound functions synchronously — it's already on Dispatchers.IO from lifecycleScope.launch(Dispatchers.IO). Do not add more withContext.
  8. brighten/darken factors inlined to 0.15f/0.85f — passing a factor parameter triggers an IDE "inline" warning; keep them hard‑coded.
  9. var attempt = 0; while (attempt++ < 10) instead of for (i in 0 until 10) — we need continue inside the redirect loop and repeat { } does not allow break/continue.
  10. Czech strings everywhere — do NOT translate to English or add i18n. This is a Czech‑only release.

13. Build & install

cd VideoDownloaderAndroid
./gradlew assembleDebug
# result: app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apk

First launch: wait ~15–30 s for the "Stahuji yt‑dlp (nightly)..." step. When the status pill switches to "Připraveno • yt‑dlp 2025.xx.xx", the Download button unlocks.


14. End of spec

If you followed sections 1–13 literally you now have an identical copy of PromptLab Downloader v2.0. Do not add a README, license, tests, or CI — just build and ship.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment