- Klikni na tlačítko "Raw" (vpravo nahoře nad textem)
- Ctrl+A (označ vše) → Ctrl+C (zkopíruj)
- 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.
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:
- Create every file verbatim. Do not "improve" code, do not rename identifiers, do not change colors or dimensions.
- Directory layout must match section 3 exactly. Use forward slashes.
- 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. - Do not add tests, README, license headers or comments beyond what appears below.
- Language of all user‑facing strings is Czech — keep it that way.
- Target:
minSdk = 26,compileSdk = 34,targetSdk = 34,applicationId = com.latarnik.videodownloader. - Signing / root
build.gradle.kts,settings.gradle.kts,gradle.propertiesand Gradle wrapper are standard Android Studio defaults — generate them if missing.
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-android0.18.1 (library + ffmpeg). On first launch the app blocks onupdateYoutubeDL(NIGHTLY)so it always runs the newest nightly binary. - YouTube fallback chain: 5 strategies with different
player_clientvariants; format18(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.DownloadswithIS_PENDINGpattern; on API 26–28 uses legacy public dir +FileProviderand O(1)renameTo. - Share intent handler:
ACTION_SEND text/plainautofills 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#0A1400on the bright button. Top‑right radial lime aurora glow. No violet, no pink, no generic purple/cyan AI look.
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)
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")
}<?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><?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><?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><?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><?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><?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><?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><?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><?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><?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><?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><?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>The root is a vertical
LinearLayout. The main card usesheight=0 + weight=1to fill remaining screen, with two weightedViewspacers inside (one above the quality section, one below) so content distributes across the whole card height without scrolling.All four
<Button>s areandroidx.appcompat.widget.AppCompatButtonwithandroid:backgroundTint="@null". This is mandatory — a plain<Button>under Material3 theme gets auto‑upgraded toMaterialButton, which tints the background withcolorPrimaryand 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>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()
}
)
}
}These all look like bugs or warnings but are load‑bearing — leave them alone:
AppCompatButtonwithbackgroundTint="@null"— mandatory. Plain<Button>underTheme.Material3.Dark.NoActionBarbecomes aMaterialButtonthat overrides any custom background withcolorPrimary. You will see the "Clear" button text disappear if you revert this.tools:ignore="LockedOrientationActivity,DiscouragedApi"— Android 16 lint wantsfullSensor/unspecifiedorientation; we intentionally keep portrait lock for a single‑screen layout.requestLegacyExternalStorage="true"+ legacy permissions gated bymaxSdkVersion— needed for the pre‑QFileProvider/renameTobranch.--force-ipv4— yt‑dlp on Android has repeated IPv6 stall issues on mobile networks; force v4.- YT format
18— progressive 360p mp4. Looks low quality but it's the only format that survives YouTube PO‑token blocks. The fallback chain tries22(720p progressive) first, then falls to18. UpdateChannel.NIGHTLYon every launch — YouTube breaks the stable yt‑dlp binary weekly; nightly keeps the app alive. Accept the ~5–30s first‑run init.suspend fun download(url)calls IO‑bound functions synchronously — it's already onDispatchers.IOfromlifecycleScope.launch(Dispatchers.IO). Do not add morewithContext.brighten/darkenfactors inlined to0.15f/0.85f— passing a factor parameter triggers an IDE "inline" warning; keep them hard‑coded.var attempt = 0; while (attempt++ < 10)instead offor (i in 0 until 10)— we needcontinueinside the redirect loop andrepeat { }does not allowbreak/continue.- Czech strings everywhere — do NOT translate to English or add i18n. This is a Czech‑only release.
cd VideoDownloaderAndroid
./gradlew assembleDebug
# result: app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/debug/app-debug.apkFirst 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.
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.