Skip to content

diegoboston/notube

Repository files navigation

NoTube

Tiny Android app — a YouTube-style green play button on the launcher — that:

  1. Accepts a shared URL from any app (browser, YouTube, Twitter, ...).
  2. Downloads the video with audio using yt-dlp via youtubedl-android directly on the device.
  3. Saves the merged .mp4 into the user-visible /Movies/NoTube/ directory in internal storage.
  4. 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 — the WAKE_LOCK permission 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.
  5. 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.
  6. 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 PlaybackHub lookup 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).
  7. 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.
  8. 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.
  9. Works fully offline with previously-downloaded videos; the player reads them straight from disk.

YouTube JS challenges

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

Project layout

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

Permissions

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:

  1. Ask for the notification permission.
  2. Open Settings so you can grant All files access (required to write into Movies/). If you decline, downloads fall back to the app-private Android/data/com.ytdlp.downloader/files/Movies/NoTube/ instead.

Building locally

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.apk

Building on GitHub

The workflow at .github/workflows/android.yml runs on every push and produces both a debug and a release APK as workflow artifacts:

  • app-debugapp-universal-debug.apk
  • app-releaseapp-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.

Screens at a glance

History card

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 …                        │
└─────────────────────────────────────────────────────┘

Player screen

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  │
└─────────────────────────────────────────────────────┘

Using the app

  • 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 m4a re-encode fallback) plus the thumbnail, the row gets an isAudioOnly flag 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 manualOrder and 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.

Cache size & storage warnings

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.

Fighting YouTube 403 / signature errors

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:

  1. 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.
  2. Use the modern web_safari player 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.

Video quality (best video / best audio, mixed)

Settings → Video quality lets you trade visual quality for disk space. Three profiles:

  • Best video qualitybestvideo[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 onlybestaudio[ext=m4a]/bestaudio/best with -x --audio-format m4a. Skips the video stream entirely and writes a small .m4a (plus thumbnail) instead of an .mp4. The row is tagged isAudioOnly = true in 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.

Surviving an uninstall

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.

Importing manually-placed videos

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 / .png exists, it's used as the thumbnail (this is exactly the layout yt-dlp writes, 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.

Updating without uninstalling

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.

Notes & limitations

  • 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors