Tiny Android app — a YouTube-style green play button on the launcher — that:
- Accepts a shared URL from any app (browser, YouTube, Twitter, ...).
- Downloads the video with audio using
yt-dlpvia youtubedl-android directly on the device. - Saves the merged
.mp4into the user-visible/Movies/NoTube/directory in internal storage. - Plays it back with screen-off / audio-only support by hosting the
ExoPlayer inside an Android
MediaSessionService(foreground media playback service). Audio keeps streaming with the screen off, the lock screen shows the transport controls, and Bluetooth headsets can pause/resume — theWAKE_LOCKpermission keeps the CPU alive for the background thread while the display is off. A headphones icon in the player's top bar lets you hide the video pane on the fly for any download, and a dedicated "Audio only" download profile skips the video stream entirely and saves a tiny.m4a(plus the thumbnail) for pure-audio rows. - Autoplays the next video when one ends. After a 2 s breather the
playback hub picks the next item in your manual ordering, loads it on
the same
MediaSession, and (if a player screen is open) silently navigates to it so the title and seek bar follow along. This works with the screen off too, turning a hand-curated history into a queue. - Prev / next on every surface. Skip-previous and skip-next are
wired up on the player top bar, the media notification, and Bluetooth
/ wired-headset hardware buttons, all routed through the same
PlaybackHublookup that autoplay uses — so the in-app buttons, the lock-screen controls, and your headset's track-skip key always agree on what "next" means. Skip-previous follows an iOS-style 3 s rewind rule (a tap partway through the track restarts the current video; only near the start does it actually jump to the previous item). - On launch shows a history of recent videos that you can drag-and-drop to reorder (long-press a card to lift it, drag, drop) and one-tap delete (removes both the row and the file from disk). The manual order is persisted and is what autoplay + prev/next walk through.
- Surfaces cache-size warnings at the top of the history list when your downloads have outgrown the configurable cache limit, or when the underlying volume has less free space than the limit you set.
- Works fully offline with previously-downloaded videos; the player reads them straight from disk.
Modern YouTube playback requires solving JavaScript challenges in the page's
player.js. This used to need a separate helper (often called ytdlp-jsc).
Starting with youtubedl-android 0.18.1 the library bundles QuickJS
inside the python runtime, and the official yt-dlp zipimport binary ships
yt-dlp-ejs. So the two combined give us full on-device JS execution with no
extra Gradle dependency. You can verify it's working in logcat:
[debug] JS runtimes: quickjs-2025-04-26
[youtube] [jsc:quickjs] Solving JS challenges using quickjs
app/
src/main/
AndroidManifest.xml <- permissions + share intent filter
java/com/ytdlp/downloader/
App.kt <- initialises yt-dlp/ffmpeg/aria2c on boot
MainActivity.kt <- Compose host, navigation, share handler
data/ <- Room entity / DAO / repository
service/
DownloadService.kt <- Foreground service running yt-dlp
PlaybackService.kt <- MediaSessionService (background audio)
ui/
VideoViewModel.kt
screens/
HistoryScreen.kt <- recent videos + delete
PlayerScreen.kt <- ExoPlayer view, prev/next, fullscreen, audio-only toggle
.github/workflows/android.yml <- builds debug + release APKs
| Permission | Why |
|---|---|
INTERNET |
yt-dlp downloads from the network |
FOREGROUND_SERVICE, _DATA_SYNC, _MEDIA_PLAYBACK |
Long-running download + media session |
WAKE_LOCK |
Keep playback running with screen off |
POST_NOTIFICATIONS |
Progress + done notifications (API 33+) |
MANAGE_EXTERNAL_STORAGE |
Write directly into the public Movies/ directory on API 30+ |
When you launch the app for the first time it will:
- Ask for the notification permission.
- Open Settings so you can grant All files access (required to write into
Movies/). If you decline, downloads fall back to the app-privateAndroid/data/com.ytdlp.downloader/files/Movies/NoTube/instead.
You only need a JDK 17 and an Android SDK (or just Android Studio).
# One-time wrapper bootstrap
gradle wrapper --gradle-version 8.10.2 --distribution-type bin
# Debug APK
./gradlew :app:assembleDebug
# -> app/build/outputs/apk/debug/app-universal-debug.apkThe workflow at .github/workflows/android.yml runs on every push and
produces both a debug and a release APK as workflow artifacts:
app-debug—app-universal-debug.apkapp-release—app-universal-release.apk(signed with the standard Android debug key so it's installable without any secret setup)
ABI splits are enabled, so smaller per-architecture APKs are also emitted alongside the universal one.
Each downloaded video is one card in the list. The chevron at the top right toggles between the expanded layout (full metadata + actions
- watched-progress strip) and a collapsed one-liner — handy when a long history starts to drown the title you're actually looking for. The collapsed/expanded state is per-card and survives scrolling and drag-to-reorder.
Expanded — chevron points up, full body visible:
┌─────────────────────────────────────────────────────┐
│ Long YouTube title that can wrap up to 3 ⌃ │
│ lines if it really needs the room │
│ Channel / uploader name │
│ │
│ ┌──────────┐ 12:34 · 87.4 MB ▶ ✎ 🗑 │
│ │ thumbnail│ │
│ └──────────┘ │
│ ▬▬▬▬▬▬▬▬░░░░░ ← watched-progress strip │
└─────────────────────────────────────────────────────┘
Collapsed — chevron flips, title clamps to a single line, everything else hides:
┌─────────────────────────────────────────────────────┐
│ Long YouTube title that can wrap up to 3 lin… ⌄ │
└─────────────────────────────────────────────────────┘
While downloading the action column swaps to a cancel button and a live progress bar + the latest yt-dlp status line take the bottom row:
┌─────────────────────────────────────────────────────┐
│ Resolving title from yt-dlp… ⌃ │
│ │
│ ┌──────────┐ Downloading · 47% ⏹ │
│ │ thumbnail│ │
│ └──────────┘ │
│ ▬▬▬▬▬░░░░░░░░ 47% · 45.6MB at 3.2MiB/s │
└─────────────────────────────────────────────────────┘
Failed downloads stay in history with the card painted in the theme's
errorContainer, a red ⚠ glyph in place of the thumbnail, and two
extra actions — retry (↻) and logs (🐞):
┌─────────────────────────────────────────────────────┐
│ Failed download title ⌃ │
│ │
│ ┌──────────┐ Failed · tap for logs ↻ 🐞 ✎ 🗑 │
│ │ ⚠ │ │
│ └──────────┘ │
│ Error: HTTP 403 forbidden … │
└─────────────────────────────────────────────────────┘
In windowed mode the player is a Scaffold with a top bar (back + audio/video toggle + delete), a video pane fitted to the source's natural aspect ratio with the fullscreen button overlaid, then the seek bar, transport row, and the uploader / file-path footer:
┌─────────────────────────────────────────────────────┐
│ ← Long video title… 🎧 🗑 │ top bar
├─────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────┐ ⛶ │
│ │ │ │
│ │ [video frame] │ │ video
│ │ (natural aspect) │ │ pane
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● │ seek
│ 3:21 12:34 │
│ │
│ ⏮ -10s ▶ / ⏸ +10s ⏭ │ transport
│ │
│ Channel name │
│ Stored at /storage/emulated/0/Movies/NoTube/… │
└─────────────────────────────────────────────────────┘
Tap ⛶ to enter fullscreen: the top bar, seek bar, transport row, footer, and the system bars all hide; the video pane grows to fill the screen. Back exits fullscreen first, then the player:
┌─────────────────────────────────────────────────────┐
│ ⛶ x │
│ │
│ │
│ │
│ [video frame] │
│ (fills screen) │
│ │
│ │
│ │
└─────────────────────────────────────────────────────┘
For audio-only rows (and any video where the user has tapped the headphones toggle) the video surface is replaced with the thumbnail poster art — the controls below stay identical and playback keeps running with the screen off:
┌─────────────────────────────────────────────────────┐
│ ← Podcast episode title 📹 🗑 │
├─────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────┐ ⛶ │
│ │ [poster artwork] │ │
│ │ (fills the pane) │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● │
│ …same seek bar / transport row / footer as above │
└─────────────────────────────────────────────────────┘
- Download: in any app (e.g. YouTube), hit Share -> NoTube. The history list immediately shows a row for the in-progress download with a live progress bar and the latest yt-dlp status line, mirrored in the notification shade.
- Play: tap any completed item. The player auto-starts. The video surface honours the source's natural aspect ratio — vertical 9:16 shorts and 21:9 ultrawides render at their intended geometry instead of being squashed into a 16:9 letterbox. The transport row underneath has skip-previous / -10s / play-pause / +10s / skip-next, and the fullscreen button overlaid on the video hides the top bar + system bars and lets the picture grow to the full screen (back exits).
- Audio only: there are two flavours.
- Per-session toggle. For any normally-downloaded video, tap the headphones icon in the top bar to hide the video pane and switch to artwork-only display — playback keeps going with the screen turned off, the lock screen shows the usual media controls, and Bluetooth / wired-headset buttons (play, pause, prev, next) all just work.
- Audio-only downloads. In Settings → Video quality pick
"Audio only" to skip the video stream entirely. yt-dlp downloads
only
bestaudio[ext=m4a](with a-x --audio-format m4are-encode fallback) plus the thumbnail, the row gets anisAudioOnlyflag in Room (DB v4 migration), and the player permanently shows the artwork in place of the video pane. These rows are typically an order of magnitude smaller than the LOWEST video profile and are perfect for podcasts, lectures, or DJ sets.
- Prev / next: every skip surface routes through the same lookup in
PlaybackHub, so the player top bar, the media notification, and a Bluetooth / headset track-skip button all advance through your manually-ordered history identically — no separate queue object to keep in sync. Skip-previous mirrors the iOS rule: more than 3 s into the track it restarts the current video, near the start it jumps to the actual previous item. The button stays disabled (top bar) or silently no-ops (notification, BT) when no neighbour exists, so a tap is never a lie. - Autoplay: when a video reaches the end, NoTube waits 2 s and then loads the next video in your manual order on the same media session. No UI is required — autoplay keeps running with the screen off — and if the player screen happens to be open it follows the new track so the title, seek bar, and resume-position machinery stay in sync. Pressing back from the 5th autoplayed video lands you on the history list, not on video #4 (each step replaces the back-stack entry instead of piling up). Seeking back into the current video, hitting pause, or picking a different row cancels the pending autoplay timer.
- Reorder: long-press any card in the history list to lift it, then
drag it up or down to move it. The card stays pinned under your finger
across swaps and the surrounding rows animate out of the way. On drop,
the new ordering is persisted as
manualOrderand immediately becomes the order autoplay walks through. - Delete: tap the trash icon next to any completed/failed item, confirm.
Deletes both the database row and the underlying
.mp4(+ thumbnail). - Failed downloads stay in history with a red error tile. Tap the row (or the bug icon) to open a full Logs screen with every line of yt-dlp output captured during the run; tap the refresh icon to retry the same URL in place. The logs screen has a copy-to-clipboard button so you can paste the output into a yt-dlp issue if the failure is real.
Settings → Cache size limit is a logarithmic slider that lets you cap
NoTube's footprint anywhere from a few hundred MB up to several tens of
GB. The chosen limit is persisted in DataStore and mirrored into
/Movies/NoTube/.notube_state.json, so it survives reinstalls and a
Clear data. The settings screen also shows a live progress bar of how
much of the configured limit is currently in use.
The cache limit is advisory — NoTube doesn't auto-prune your videos — but the history screen surfaces a yellow warning banner at the top whenever either of the following becomes true:
- Over the configured limit: your
Movies/NoTube/folder is using more bytes than your configured cache size. The banner tells you the exact figures and suggests deleting some videos to free space. - Volume nearly full: the volume backing
Movies/NoTube/has less free space than your configured cache size, so the next download is likely to fail mid-stream. The banner shows free-on-volume vs. configured-limit so you can decide whether to shrink the limit or free up space at the OS level.
Both banners disappear automatically as soon as the underlying numbers go back into the green — no need to dismiss them by hand.
YouTube changes its JS challenges and signature scheme on a near-monthly
basis, which is the usual source of HTTP error 403 forbidden reports. The
app does two things to mitigate this automatically:
- Auto-update yt-dlp on boot. On every app start we call
YoutubeDL.updateYoutubeDL(NIGHTLY)before marking the engine ready. First launch downloads ~3 MB; subsequent launches are a quick HEAD request. The current version is always shown in Settings → About. - Use the modern
web_safariplayer client. Every request adds--extractor-args "youtube:player_client=default,web_safari,android,ios", which is the workaround documented in yt-dlp/yt-dlp#15569.
If a download still 403s after the auto-update, open Settings → About → Update yt-dlp (nightly) to force-pull the freshest binary, then tap the retry button on the failed row.
Settings → Video quality lets you trade visual quality for disk space. Three profiles:
- Best video quality —
bestvideo[ext=mp4]+bestaudio[ext=m4a] / bestvideo+bestaudio / best[ext=mp4] / best. Largest stream available, typically 1080p on YouTube. - Lowest (space saver) —
worstvideo[ext=mp4]+bestaudio[ext=m4a] / worstvideo+bestaudio / worst[ext=mp4] / worst. Smallest stream available, typically 144p on YouTube. Roughly 10× smaller files than BEST. - Audio only —
bestaudio[ext=m4a]/bestaudio/bestwith-x --audio-format m4a. Skips the video stream entirely and writes a small.m4a(plus thumbnail) instead of an.mp4. The row is taggedisAudioOnly = truein Room (a DB v4 migration adds the column, defaulted to 0 for pre-existing videos), and the player permanently shows the artwork in place of the video pane. Typical sizes are smaller again than LOWEST, and these files play fine in any other audio app since they're vanilla m4a containers.
The key trick — audio is always best in both video profiles. yt-dlp's
+ syntax tells it to download the video and audio streams
independently (YouTube serves them as separate .m4a and .mp4
URLs), and the on-device ffmpeg merges them losslessly into one MP4
container. So "space saver" gives you tiny 144p video but full-quality
audio — perfect for listening with the screen off, where the video
frames don't matter anyway. The "Audio only" profile takes that one
step further and drops the video URL entirely.
For the rare site that only ships combined audio+video streams (Twitter,
Instagram, some Vimeo), each profile falls back through /best[ext=mp4]/best
or /worst[ext=mp4]/worst (or just /best for audio-only) so the
download still succeeds, just without the mix-and-match advantage.
The chosen profile is read at the moment a download starts (mid-flight
downloads aren't re-resolved if you flip the setting), persists in
DataStore, and is also written into the manifest in Movies/NoTube/.notube_state.json
so reinstalls keep your preference.
The actual .mp4 files in /storage/emulated/0/Movies/NoTube/ survive an
uninstall on their own (shared storage isn't tied to the app's data directory).
What used to vanish was the metadata: titles, uploaders, durations, sizes and
your cache-size preference, all of which lived in the app-private Room DB.
To make the app pick up where it left off after a reinstall or an
App info → Storage → Clear data, NoTube writes a JSON manifest
/Movies/NoTube/.notube_state.json next to the videos, debounced ~2 s after
every change. On startup, if the local DB is empty and the manifest is
readable, the app re-imports every entry whose backing video file is still on
disk and restores your cache-size limit. A toast tells you how many videos
were brought back. The leading dot keeps Android's MediaScanner from picking
the file up; it's pure JSON, safe to inspect or hand-edit.
If you reinstall and don't grant All files access until after the first launch, no problem — the restore runs again every time the activity resumes, so as soon as the permission lands the history shows up.
If you drop your own .mp4 (.mkv, .webm, .mov, .m4v, .3gp) files
straight into Movies/NoTube/ — for example by adb push, by sideloading from
a desktop, or by restoring a backup that doesn't include the manifest — NoTube
will pick them up too. On launch and on every resume, the app sweeps the
folder and adopts any video file that isn't already in its history:
- the title defaults to the filename (sans extension),
- if a sibling
<basename>.jpg/.webp/.pngexists, it's used as the thumbnail (this is exactly the layoutyt-dlpwrites, so files copied from another machine are picked up nicely), - the duration is probed locally with
MediaMetadataRetriever.
These rows are written into the manifest like any other, so subsequent reinstalls restore them with the same metadata.
By default Android Gradle Plugin signs debug builds with a per-machine
~/.android/debug.keystore and leaves release builds unsigned. That means
two consecutive CI builds (or a debug build from your laptop and a release
build from the runner) end up signed with different certificates, and
when you try to install the second APK on top of the first the package
manager refuses with INSTALL_FAILED_UPDATE_INCOMPATIBLE. The only way
out is to uninstall first — which wipes the Room DB along with it.
NoTube ships a stable, self-signed RSA-2048 keystore at
app/keystore/notube.keystore (password notubeapp, alias notube,
valid until ~2126) and pins both debug and release to it via
signingConfigs. Every build on every machine is signed with the same
certificate, so adb install -r and "tap-to-install" updates from a
file manager both work without uninstalling.
This is a personal sideload key, not a Play Store key — it's deliberately committed to the repo so anyone who clones the project gets the same signature. If you ever publish to the Play Store, swap it for a real release keystore stored as a GitHub secret.
The versionCode is also auto-bumped on CI from GITHUB_RUN_NUMBER
(versionName = "1.0.<run>"), so each successive build is a strict
upgrade rather than a same-version reinstall — you get the proper "Update"
flow in your file manager / installer.
- The very first launch unpacks ~30 MB of native binaries (Python, ffmpeg, aria2c, QuickJS) out of the APK; this takes a few seconds and is shown in the empty state hint.
- yt-dlp can be updated at runtime (
YoutubeDL.getInstance().updateYoutubeDL). The current version of the app does not expose a UI for that yet, but you can wire it up to a settings screen if you need always-fresh extractors. - Files are kept under
/storage/emulated/0/Movies/NoTube/so they show up in the system Gallery and any other media app.