Multi-module, feature-sliced Android project. Shared build setup lives in Gradle convention plugins, so every module's build file is a few lines long.
build-logic/convention quicktube.android.{application,library,library.compose,feature,room}
app Application, MainActivity, ShareActivity, NavHost, Koin wiring
core/
model Plain data classes (MediaItem, DownloadTask, AppSettings…) — no Android deps
common Formatting, URL parsing, LoadState
designsystem Theme (colors, typography), shared buttons/headers
ui Shared composables: thumbnails, video cards/rows, empty/error states, dialogs
navigation Type-safe @Serializable routes shared by all features
database Room: downloads, sites, history, bookmarks, search history
datastore DataStore-backed settings
extractor MediaExtractor interface + NewPipe implementation, WebMediaFinder (FB/TikTok/X/OG tags)
data Repository interfaces + implementations (the only thing features talk to)
download DownloadManager: queue, parallel ranged HTTP, Media3 merge/remux, foreground service
media PlaybackService (Media3 session), PlayerController, chunked YouTube DataSource
playerui VideoPlayerBox, seek bar, speed menu, fullscreen helper
downloadsheet App-wide "Download video as" sheet (used by every screen + the share popup)
feature/
home Download tab (Search / YouTube / Music / More) + How-to screens
search Search + results
video Video page, channel & playlist pages
library Play tab, Downloading screen
player Full player + mini player
browser WebView browser with media sniffing, sites, history, saved pages
settings Settings + sub-screens
tools Recycle bin, vault, status saver, cleaners, app uninstaller
gallery Gallery tab: photos by month, albums, full-screen viewer (MediaStore via GalleryRepository)
- Features never depend on other features. They depend on
core:*only. Cross-feature navigation goes through routes incore:navigationand callbacks wired inapp/navigation/AppNavHost.kt. - Features depend on interfaces. Repositories (
core:data) andDownloadManager(core:download) are interfaces bound in Koin, so implementations can be swapped (another extractor, fakes for tests) without touching UI code. - One source of truth per concern. Room holds downloads, DataStore holds settings, and
PlayerControllerholds playback. Screens observe flows from these. - DI: Koin. Each module exposes one Koin module, and the app lists them in
app/di/FeatureModules.kt. Koin, Room, Navigation Compose and DataStore all support Kotlin Multiplatform, which keeps a future iOS port open (see below).
- Create
feature/<name>/build.gradle.kts:plugins { alias(libs.plugins.quicktube.android.feature) } include(":feature:<name>")insettings.gradle.kts, and add it toapp/build.gradle.kts.- Add a route to
core/navigation/Routes.kt, acomposable<YourRoute>inAppNavHost, and your Koin module tofeatureModules.
- Download: screen →
DownloadSheetController.open(url)→DownloadSheetViewModelresolves formats (MediaRepositoryfor YouTube etc.,WebMediaFinderfor other pages) →DownloadManager.enqueue→ Room row →DownloadEngineworker (video + audio at once, each in 4 MB ranges over up to 8 connections, chunk map in.part.chunks, re-extract on expired links or unsavable codecs) →MediaMerger(Media3 extractors + MP4 muxer in-process, platform muxer for WebM / as fallback) on app-private storage → moved to the download folder → media scan → notification. - Batch download: playlist/channel page →
BatchDownloader(app-wide) resolves each video's formats a few at a time and enqueues the closest match to the chosen quality. - Share from another app:
ShareActivity(translucent, own task) → same sheet → finishes when the sheet closes. - Gallery:
GalleryRepository(core:data) pages the MediaStore Files table newest first (120 rows, then 600 per page) and reloads on a content observer →GalleryViewModelbuilds month sections / albums off the main thread → grids draw MediaStore's cached thumbnails from a small LRU. Videos open in the app's player; deleting goes through the system trash dialog. - Playback:
PlayerController.play(items)→MediaController→PlaybackService(ExoPlayer). Separate video+audio URLs are played with aMergingMediaSource. YouTube's CDN gets bounded range requests viaChunkedHttpDataSource, because open-ended reads are throttled.PlayerControlleralso keeps the playback history (PlaybackHistoryRepository, Room, capped at 300, least recently opened evicted) and resumes from it, keyed by file path or page URL, following Settings → Player (AppSettings). - Picture-in-picture: the floating window is always an activity in its own task, so the app stays usable (and can be
reopened) behind it: the gallery player's, or
FloatingPlayerActivityfor the video page and the library player (started by the PiP button, or when going home from a playing video). While it floats, the page's player shows a "Show here" placeholder. Back always closes the video. Screens report their video throughPipState(core:playerui). - Our games (home row + See all): Firebase Remote Config, cached and refreshed once per interval; see PROMO_GAMES.md.
The domain is already isolated: core:model, core:common, core:data interfaces, core:database (Room KMP) and
core:datastore (DataStore KMP) can move to commonMain with mostly mechanical changes, and the Compose UI can move to Compose
Multiplatform. Platform-specific pieces would need iOS counterparts: NewPipe Extractor (JVM-only), the download service, MediaMuxer,
the Media3 player and the WebView. Note that Apple doesn't allow YouTube downloading on the App Store.