Skip to content

Add support for Android, continuing #682 - #894

Open
leobischof wants to merge 33 commits into
Tyrrrz:primefrom
leobischof:android-support-upstream
Open

leobischof wants to merge 33 commits into
Tyrrrz:primefrom
leobischof:android-support-upstream

Conversation

@leobischof

Copy link
Copy Markdown

Builds on #682 by
@J0nathan550
, which is where the Android port and the bulk of the design originate.
That branch no longer merges with current prime; this is it merged forward and reworked
until it actually runs on a device. If you would rather take this as commits on #682, or
have @J0nathan550 carry it, I am happy either way — this is offered as "here is the rest
of the way", not as a replacement for their work.

Tested on a physical phone (Android 16, arm64): search, Google sign-in, download and
muxing all complete, for MP4 and MP3 output, on mobile data and Wi-Fi, signed in and
signed out.

Shape of the change

YoutubeDownloader multi-targets net10.0;net10.0-android. Desktop-only properties and
packages are conditioned on net10.0, so the desktop build is unchanged. A new
YoutubeDownloader.Android head hosts the app; MainSingleView is the chrome-less root
for ISingleViewApplicationLifetime, mirroring MainView.

Most of the diff is that head plus platform plumbing. The shared code changes are small
and each is noted below.

Things that were not obvious

Avalonia 12 moved single-view app binding. AvaloniaMainActivity is no longer
generic; the app type is bound via AvaloniaAndroidApplication<TApp> on an [Application]
class.

A Debug APK cannot be sideloaded. Fast Deployment leaves the managed assemblies out of
the package and expects an IDE to push them over adb, so a hand-installed Debug build
aborts with No assemblies found in .../.__override__/arm64-v8a. The Android project
therefore sets EmbedAssembliesIntoApk unconditionally.

The Java HTTP handler cannot be used. It wraps an OkHttp response in a Java
InputStream, and closing that stream drains the rest of the connection — a blocking
network read. HttpClient disposes a response on whichever thread the await resumed on,
which for work started from a command is the UI thread, and StrictMode forbids network I/O
there:

android.os.NetworkOnMainThreadException
  at Java.IO.InputStream.Close()
  at System.Net.Http.HttpResponseMessage.Dispose()
  at YoutubeExplode...StreamClient.TryGetContentLengthAsync

The project sets UseNativeHttpHandler=false, which is also the stack the desktop build
already uses.

Android's picker returns a Storage Access Framework URI, not a path. Passing it on
produced Read-only file system : '/content:'. DialogManager translates locations on
the primary volume to a real path — necessary regardless, since FFmpeg takes a path on its
command line and cannot write into a content URI. Anything with no filesystem mapping (SD
card, cloud provider) returns null rather than failing later with an opaque IO error;
telling the user why is still missing.

Android's CookieManager exposes only name=value. No domain, no path, no expiry —
the platform API has no way to report them. Two consequences: IsApplicableFor compared
against Domain and silently discarded every captured sign-in cookie, and
AuthSetupViewModel.IsAuthenticated required an expiry in the future, which can never
hold there. Cookies now inherit the host they were read from, and one with no expiry is
treated as the session cookie it is rather than as an expired one.

Tooltips need a pointer that hovers. A failed download reported its reason only
through the status cell's tooltip, so on a touch screen the row said "Failed" and nothing
else. Tapping it now shows the reason; with a pointer it still copies, as before.

FFmpeg — the part that needs a decision

The app needs an FFmpeg executable for Android. It is shipped as libffmpeg.so, because
the native library directory is the only place Android will execute a binary from, which
also requires extractNativeLibs="true".

No maintained prebuilt is usable. FFmpeg has no native MP3 encoder, so MP3 output needs
libmp3lame:

Source FFmpeg encoders ABIs minSdk
Khang-NT (what #682 used) 2018 lame, shine, vorbis, opus, fdk-aac arm64 + x86_64 low
hzw1199 8.1.1 none (pure LGPL) arm64 only 28
this PR 8.1.2 lame, vorbis, opus, x264 arm64 + x86_64 23

So .github/workflows/build-ffmpeg-android.yml cross-compiles it, and
Download-FFmpeg-Android.ps1 fetches what that publishes. The workflow has to be run
once from the Actions tab before an Android build can fetch anything
— that is real
friction and I would rather name it than bury it.

Two things worth flagging:

  • x264 makes the binary GPL rather than LGPL. It is there only so that -preset
    exists: YoutubeExplode.Converter appends -preset <name> to every command
    unconditionally, even when every stream is copied, and preset is a private option of
    an encoder rather than a global one. Without x264 every download failed at the muxing
    step with Unrecognized option 'preset'. Its assembly is disabled since it is never
    asked to encode. If you would prefer the binary stay LGPL, the alternative is a change
    in YoutubeExplode.Converter to omit the preset when nothing is being encoded, and I am
    happy to do that instead.
  • The binary is built against bionic, so CI cannot execute it. The workflow asserts its
    contents — 16 KB page alignment, the encoders, and x264's option table — and says so
    rather than implying it was run.

Known gaps

  • The layout is upstream's, with a phone top bar and bottom bar around it. It is not a
    redesign.
  • Picking a storage location with no filesystem mapping cancels silently.
  • The FFmpeg download is a build-time fetch from a GitHub release, not a vendored binary.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB

J0nathan550 and others added 30 commits June 12, 2025 02:25
Merges the Android port (PR Tyrrrz#682 by J0nathan550) into current prime and rebuilds
it as a thin Android head over upstream's layout, rather than carrying the PR's
four-project split. Both the desktop and Android heads build clean.


---


Führt den Android-Port (PR Tyrrrz#682 von J0nathan550) mit dem aktuellen prime zusammen
und baut ihn als schlanken Android-Head auf der Upstream-Struktur neu auf, statt
die Vier-Projekt-Aufteilung der PR zu übernehmen. Desktop- und Android-Head bauen
beide fehlerfrei.


---


### Why the PR could not be merged as-is

The branch was 123 commits behind and conflicting. In the meantime upstream moved
to net10 and Avalonia 12, replaced `.sln` with `.slnx`, adopted central package
management, extracted eight utility files into the PowerKit package, and — most
relevant here — reworked authentication onto `Avalonia.Controls.WebView`. That
package ships `net10.0-android36.0`, so the PR's 464-line Android auth workaround
is obsolete and was dropped in favour of upstream's.

Conflicts were resolved toward upstream throughout. The PR's UI rework
(`DashboardViewModel`, `SettingsView`, the dialog views) was not carried over.

### Shape

`YoutubeDownloader` now multi-targets `net10.0;net10.0-android` so one source tree
backs both heads; desktop-only properties and the `Avalonia.Desktop`/`MacBundle`
packages are conditioned on the desktop target, and `Program.Main` is behind
`#if !ANDROID`. `YoutubeDownloader.Android` is a new head with the activity,
manifest and resources.

Against upstream, the shared projects differ in seven files only:

```
Core/Downloading/FFmpeg.cs          CustomCliFilePath override
App.axaml.cs                        ISingleViewApplicationLifetime branch
Program.cs                          #if !ANDROID around the desktop entry point
ViewModels/.../DownloadViewModel.cs "open file" disabled on Android
Views/MainSingleView.axaml(.cs)     windowless root for Android
YoutubeDownloader.csproj            multi-targeting
```

### Fixes carried in

- The FFmpeg download target tested for `<abi>/ffmpeg` while the script writes
  `<abi>/libffmpeg.so`, so it re-downloaded on every build; it also hard-required
  `pwsh`, which is absent on stock Windows. It now tests the real filename and
  falls back to Windows PowerShell.
- `extractNativeLibs="true"` is now explicit. FFmpeg is executed from the native
  library directory, which only works if it is unpacked as a real file; on the
  modern default it stays mapped inside the APK and silently resolves to nothing.
- Dropped the explicit `AndroidNativeLibrary` items — the SDK already collects
  `lib*.so` from ABI-named folders, so each library was packaged twice (XA4301).
- `minSdk` raised 21 → 23, which the AndroidX lifecycle runtime requires.
- The wake lock became the `KeepScreenOn` window flag, driven off the public
  `Downloads` collection. It cannot leak if the process dies mid-download, and it
  needs no hook inside the shared view models.
- Added a `CSharpierFormat` passthrough: the target is only defined in the inner
  build, so multi-targeting broke CI's format step with MSB4057.

### Verified

Desktop `net10.0` and the Android head both build in Release with 0 errors, and
`dotnet build -t:CSharpierFormat` passes. Debug APK 37 MB, Release 73 MB, with
FFmpeg present for arm64-v8a and x86_64.

Not verified: that it runs on a device. No Android device or emulator was
available, so nothing beyond "it builds and packages" is claimed. Two known
caveats — the bundled FFmpeg is from 2018 and lacks the 16 KB page alignment
Android 16 requires (XA0141), and the UI is upstream's desktop layout, which is
not adapted for a phone screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dialogs declared fixed widths of 380-500px, so on a phone they rendered wider than
the screen and were clipped on both sides. They now cap at the screen width while
keeping their existing size wherever there is room.


---


Dialoge hatten feste Breiten von 380-500px und wurden auf einem Telefon breiter als
der Bildschirm gerendert und an beiden Seiten abgeschnitten. Sie werden jetzt auf
die Bildschirmbreite begrenzt und behalten ihre bisherige Größe, wo Platz ist.


---


Observed on a ~400dp phone screen: the message box, the auth dialog and both
download setup dialogs overflowed the display, cutting off their titles and the
buttons along the edges.

`Width` is a demand, not a preference, so the dialog was laid out at 500px
regardless of how much room existed. Each affected view now also carries

```
MaxWidth="{Binding $parent[TopLevel].Bounds.Width}"
```

which clamps it to whatever the window or screen actually offers. Desktop
rendering is unchanged by construction: when the window is wider than the dialog,
the bound maximum exceeds `Width` and `Width` still decides.

`AuthSetupView` needed one extra change. It used `MinWidth="450"`, and a minimum
outranks a maximum, so clamping alone would not have helped - it became `Width`,
which the new maximum can override. Its fixed `Height` is likewise capped, so the
embedded sign-in view cannot grow taller than the screen in landscape.

Verified: `net10.0` and `net10.0-android` both build with 0 warnings, and
`dotnet build -t:CSharpierFormat` passes. The on-device result is not verified
here; it needs a look on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Choosing a download location on Android crashed with "Read-only file system:
'/content:'". The picker returns a Storage Access Framework URI rather than a path,
and it was being passed on as if it were one. Locations on the device's primary
storage are now translated to a real path.


---


Die Wahl eines Download-Ordners stürzte unter Android mit "Read-only file system:
'/content:'" ab. Die Auswahl liefert eine Storage-Access-Framework-URI statt eines
Pfads, die aber wie ein Pfad weitergereicht wurde. Orte im primären Speicher des
Geräts werden jetzt in einen echten Pfad übersetzt.


---


Reported from a device as:

```
[System.IO.IOException]: Read-only file system : '/content:'
  at PowerKit.Extensions.DirectoryExtensions.CreateForFile
  at DownloadMultipleSetupViewModel.ConfirmAsync
```

All three path prompts ended in `TryGetLocalPath() ?? Path.ToString()`. On desktop
the first branch always wins. On Android it returns null, so the raw URI -
`content://com.android.externalstorage.documents/tree/primary%3ADownload` - was
handed back as a directory path. `Path.Combine` then produced a nonsense path and
the first `Directory.CreateForFile` threw.

A URI is not a viable substitute here even in principle: downloads are muxed by the
FFmpeg binary, which takes a path on its command line and cannot write into a
content URI. A real path is required.

`ToLocalPath` now sits between the pickers and their callers. Locations on the
primary volume map onto `/storage/emulated/0/...`, which the app can use because it
already holds all-files access. The translation is pure string handling, so it stays
in the shared project without pulling in an Android-specific API.

Known gap: locations with no filesystem mapping - SD cards, cloud providers - return
null, which the callers read as a cancelled prompt. That trades a crash for a
silent no-op, which is safer but still confusing, and wants a proper message.

Verified: `net10.0` and `net10.0-android` build with 0 warnings and the format check
passes. The device path is not verified here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Adds a workflow that cross-compiles FFmpeg 8.1.2 for Android with libmp3lame,
libvorbis and libopus, for both ABIs, and points the download script at the
release it publishes. Replaces a third-party binary last published in 2018.


---


Fügt einen Workflow hinzu, der FFmpeg 8.1.2 für Android mit libmp3lame, libvorbis
und libopus für beide ABIs cross-kompiliert, und richtet das Download-Skript auf
das dabei veröffentlichte Release aus. Ersetzt ein Fremdbinary von 2018.


---


### Why build it rather than take a prebuilt

The app offers MP3, Vorbis and Opus output, and FFmpeg has no native MP3 encoder -
it needs libmp3lame. Surveying what is actually available:

| Source | FFmpeg | encoders | ABIs | minSdk |
|---|---|---|---|---|
| Khang-NT (what we shipped) | 2018 | lame, shine, vorbis, opus, fdk-aac | arm64 + x86_64 | low |
| hzw1199 | 8.1.1, NDK r28 | none (pure LGPL, no externals) | arm64 only | 28 |
| ivanarh | 2018 fork | lame, shine, vorbis, opus | arm64 | low |

Nothing is both current and complete. Taking the modern one would have broken
"Download as MP3" outright, dropped x86_64, and forced minSdk from 23 to 28.

Building it ourselves avoids every one of those: FFmpeg 8.1.2 - the same 8.1 line
upstream pins for the desktop app - with the encoders present, both ABIs kept, and
compiled against API 23 so the app's existing minSdk still holds.

### On the 16 KB page size

Worth recording, because it was the original reason to touch this at all: build
warning XA0141 claims the bundled library lacks 16 KB page alignment. Reading the
ELF program headers, the 2018 binary already aligns its PT_LOAD segments to 0x10000
(64 KB), which satisfies Android 16. The warning appears to be a false positive, so
alignment was never the real problem - the eight-year-old codebase was.

The new build links with `-Wl,-z,max-page-size=16384` regardless, and the workflow
asserts the alignment and the presence of all three encoders before publishing, so
a binary that cannot make an MP3 fails the build instead of reaching a release.

### Running it

Actions tab, "build-ffmpeg-android", Run workflow. It publishes
`ffmpeg-8.1.2-android`, which `Download-FFmpeg-Android.ps1` then fetches. The script
also verifies each download is really an ELF binary, so a stray HTML error page
cannot be packaged into the APK as if it were FFmpeg.

Not yet verified: the workflow has not been run, so the binaries do not exist yet
and no APK has been built against them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
A hand-installed Debug APK aborted on launch because Fast Deployment had left
the managed assemblies out of it. Turns it off for this project and adds a
workflow that builds the Release APK on a runner, since the Release link needs
more memory than the dev machine has spare.

---

Ein von Hand installiertes Debug-APK brach beim Start ab, weil Fast Deployment
die verwalteten Assemblies nicht mit einpackt. Schaltet das fuer dieses Projekt
ab und ergaenzt einen Workflow, der das Release-APK auf einem Runner baut, da
das Release-Linken mehr Speicher braucht, als der Entwicklungsrechner frei hat.

---

### The crash

    Cannot open assembly '/data/.../files/.__override__/arm64-v8a': No such file
    No assemblies found in '/data/.../files/.__override__/arm64-v8a'

Fast Deployment is on by default for Debug: the APK ships without the managed
assemblies and the IDE pushes them separately over adb. That is fine when you
deploy from the IDE and silently fatal when someone installs the APK by hand.
Confirmed by reading the published package - 713 entries, not one assembly.

EmbedAssembliesIntoApk now holds for every configuration, because every APK this
project produces is meant to be sideloaded.

### Why the build moved to CI

The Release configuration runs the trimmer and r8, and that link step was
OOM-killed twice locally. Debug is not a substitute for it, as above, so the
build belongs somewhere with real memory.

The workflow refuses to upload an APK that would fail the same way: it asserts
the package actually contains managed assemblies and libffmpeg.so, so the class
of failure that produced the broken release cannot reach a release again.

It also stamps the version. Directory.Build.props leaves it at 999.9.9-dev,
which is deliberate - an unstamped build should be obvious - but that string is
what the last release showed in its About screen.

### Signing

The APK the workflow uploads is signed with the runner's own throwaway debug
key, not the one the published releases carry, so it has to be re-signed before
it is published or Android will refuse to install it over an existing copy. The
key in question is the one .NET Android generates at
%LOCALAPPDATA%\Xamarin\Mono for Android\debug.keystore - not ~/.android, which
holds a different one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
The binaries were downloaded but never ended up in the APK when building from a
fresh clone, because the item glob that collects them runs before the download.

---

Die Binaries wurden zwar heruntergeladen, landeten bei einem Build aus einem
frischen Clone aber nie im APK, weil das Item-Glob, das sie einsammelt, vor dem
Download laeuft.

---

The SDK collects native libraries with a default AndroidNativeLibrary glob, and
default items are evaluated during project evaluation - which finishes before any
target runs. DownloadFFmpegAndroid runs at BeforeBuild, so on a clean checkout
the glob sees an empty directory, the download then fills it, and the packaging
step still has nothing to package.

This never showed up locally: the second and every later build on a machine
evaluates with the files already on disk, so the glob finds them. It took a
clean CI build to expose it, where the verification step caught an APK with 218
managed assemblies, the right version, and no libffmpeg.so at all.

The target now registers the two libraries itself, removing them first so the
case where only one was already present does not package that one twice
(XA4301).

Also fixes the target's condition, which required *both* libraries to be missing
before it would run - a directory holding only one of them could never repair
itself - and anchors the probed paths to the project directory rather than
leaving them relative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
The dialog card was drawn narrower than its own contents, the sign-in page did
not fit, a failed download gave no way to see why, and signing in left the user
looking at a copy of YouTube.

---

Die Dialogflaeche wurde schmaler gezeichnet als ihr eigener Inhalt, die
Anmeldeseite passte nicht hinein, ein fehlgeschlagener Download bot keine
Moeglichkeit zu sehen warum, und nach dem Anmelden blieb eine Kopie von YouTube
stehen.

---

### The dialog card was smaller than the dialog

DialogOverlayPopupHost paints the dialog's background and carried a 48px margin.
It does not constrain the dialog inside it, though - the content is measured
against the window and simply overflows - so on a phone the white panel came out
96px narrower than the text sitting on it, and the text ran off both edges.

Removing the margin makes the card and the content agree, because every dialog
already bounds its own size to the window. On a desktop window, where a dialog is
far narrower than the window anyway, nothing changes.

### Google's sign-in page did not fit

The auth dialog is a fixed 450 tall, which cut the sign-in form off below the
fold on a phone, and the page lives in a native web view that does not scroll the
dialog around it. It now fills the screen height on Android and stays 450 on
desktop.

### A failed download would not say why

The reason is captured, but the only way to reach it was the status cell's
tooltip, which needs a pointer that hovers. A touch screen has none, so the row
said "Failed" and nothing else, with no way to find out more. Tapping it now
shows the reason. With a pointer it still copies, as before, since the tooltip
has already displayed it.

This is also how the current download failure gets diagnosed: nothing about it is
reproducible on the build machine, and the device is not reachable over adb.

### Signing in left YouTube on screen

The login flow ends by redirecting to the YouTube home page, which the dialog
then rendered in its embedded browser. The cookies are captured at that point and
they are the only reason the dialog exists, so it closes instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Every download failed before it started with NetworkOnMainThreadException. Also
closes the sign-in dialog when the flow actually ends, stops the app reporting
itself as signed out right after signing in, and gives the phone layout a top and
a bottom bar.

---

Jeder Download schlug mit NetworkOnMainThreadException fehl, bevor er begann.
Schliesst ausserdem den Anmeldedialog, wenn der Ablauf tatsaechlich endet,
behebt dass die App sich direkt nach dem Anmelden als abgemeldet meldete, und
gibt dem Telefon-Layout eine obere und eine untere Leiste.

---

### The download failure

    android.os.NetworkOnMainThreadException
      at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork
      at Java.IO.InputStream.Close()
      at Android.Runtime.InputStreamInvoker.Dispose(Boolean)
      at System.Net.Http.HttpResponseMessage.Dispose()
      at YoutubeExplode...StreamClient.TryGetContentLengthAsync
      at VideoDownloader.GetDownloadOptionsAsync
      at DashboardViewModel.EnqueueDownload

Android's default HTTP handler wraps an OkHttp response in a Java InputStream, and
closing that stream drains the rest of the connection - a blocking network read.
HttpClient disposes a response on whichever thread the await resumed on, which for
work started from a command is the UI thread, and StrictMode forbids network I/O
there.

Switching the app to SocketsHttpHandler removes the whole class of problem: its
I/O goes through .NET sockets, which StrictMode does not police and which do not
block on close. It is also the stack the desktop build has always used, so this
makes the two agree rather than introducing something new.

Note what this was *not*, since both were checked first: FFmpeg resolves its path
at download time rather than at startup, and the destination directory is writable
- the app creates a placeholder file there before queueing anything.

### The sign-in dialog stayed open

Closing it was conditional on IsAuthenticated, which asks whether the cookies carry
a future expiry date. That is a question about the cookies, not about whether the
flow finished, and on Android the answer is always no. It now closes when the
browser reaches the YouTube home page, which is how the flow ends.

### The app reported itself as signed out

Same root cause, and the reason the condition above could never hold. Android's
CookieManager exposes only the "name=value" pairs from the cookie header - the
platform API carries no expiry - so every cookie arrives with Expires at its
default, and a check for "expires in the future" fails for all of them. A cookie
with no expiry is a session cookie, valid until the session ends rather than
already lapsed, and is now treated as such.

### Layout

A top bar carrying the app name, and a bottom bar with authentication, settings,
restart-failed and remove-completed. They live in MainSingleView, the Android-only
root, rather than in DashboardView, which the desktop build shares: a desktop
window has a title bar and a pointer that can reach it, a phone has neither, so on
Android those two actions move out of the top corner and into thumb reach.

The sign-in dialog is also capped at 70% of the window height rather than filling
it, which needs arithmetic a binding cannot do - hence FractionOfConverter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Adds the readme's Ukraine statement as a button in the phone top bar, enlarges
the settings dialog for touch, and records the app's own inputs alongside an
unexpected download error.

---

Ergaenzt die Ukraine-Aussage aus der Readme als Schaltflaeche in der oberen
Leiste, vergroessert den Einstellungsdialog fuer die Bedienung per Finger, und
haelt bei einem unerwarteten Download-Fehler die Eingaben der App mit fest.

---

### Ukraine

Flag and text together form the button, linking to https://tyrrrz.me/ukraine -
the same link the readme badge carries. The flag is two rectangles rather than an
emoji or an image, so it renders identically regardless of the device's emoji
font. Opened through TopLevel.Launcher rather than a shell execute, since Android
has no shell to hand a URL to.

### Settings

That dialog fits a lot of rows into a small space, which suits a desktop and a
mouse. On Android it now takes the full window width, rows are taller, and the
type is larger. The combo box in particular was 22px tall, which is below every
touch-target guideline there is.

All of it is conditional, so the desktop dialog is unchanged.

### Error context

An unexpected error now carries the number of auth cookies in use and the FFmpeg
path it resolved. A bare HTTP status from YouTube says nothing about which of the
app's own inputs produced it, and on a phone there is no debugger to attach and no
log to read - a screenshot of the error dialog is the entire diagnostic channel,
so it has to carry that context itself. Errors YouTube explains itself are left
short, as before.

### On the download failure this does not fix

The NetworkOnMainThreadException is gone, and what it was hiding is a 403 from
YouTube on the stream URL. It was almost certainly there all along: the crash came
from disposing the response while leaving the using block, which happens during the
unwind of the 403 that EnsureSuccessStatusCode had already thrown.

The same code path succeeds on desktop - checked, twenty download options for the
same kind of request - so this is specific to the device rather than to YouTube or
to the version of YoutubeExplode pinned here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
leobischof and others added 3 commits September 14, 2026 23:55
Platform-conditional sizes in style setters threw InvalidCastException the moment
the style was applied, on desktop as well as on Android. Moves them to a styles
file loaded only where there is a touch screen.

---

Plattformabhaengige Groessen in Style-Settern warfen eine InvalidCastException,
sobald der Style angewendet wurde - unter Android wie auf dem Desktop. Verschiebt
sie in eine Styles-Datei, die nur dort geladen wird, wo es einen Touchscreen gibt.

---

    [System.InvalidCastException]: Setter value '17' is not a valid value for
    property 'FontSize'
      at Avalonia.Styling.Setter.Instance
      at Avalonia.Styling.StyleBase.Attach

A Setter assigns its value as-is. An {OnPlatform} inside one yields the string
"17", and nothing converts it to the double FontSize wants, so the app died as
soon as the first ComboBox or TextBox was styled. As a direct property assignment
- Height, Width, IsVisible, Margin - OnPlatform is fine, because there the target
type is known when the XAML is compiled; those uses are kept.

The touch sizes now live in TouchStyles.axaml, which App.Initialize adds only when
running on Android. It is referenced as a type rather than by resource URI, so a
rename is a compile error rather than a crash on a device that cannot be tested
here.

Verified by running the desktop app three ways: with the touch styles forced on,
with them off, and with the settings dialog opened on load to exercise the
remaining OnPlatform values in it. All three start and render clean; before this,
the first of them reproduced the crash above.

### Two more async void handlers hardened

An exception out of an async void method is unhandled and takes the process down -
the same shape of failure as the crash above. The Ukraine link handler now
tolerates a device with nothing registered for https, and the download-failure
context, which touches the file system to locate FFmpeg, can no longer throw while
describing a failure inside the failure path of another async void method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Builds FFmpeg with x264 so that "-preset" is a recognised option, and fills in the
domain on cookies the Android WebView hands back without one.

---

Baut FFmpeg mit x264, damit "-preset" eine bekannte Option ist, und ergaenzt die
Domain bei Cookies, die die Android-WebView ohne eine solche zurueckgibt.

---

### Every download failed at the muxing step

    Unrecognized option 'preset'.
    Error splitting the argument list: Option not found

YoutubeExplode.Converter appends "-preset <name>" to every command it builds,
unconditionally - confirmed in its source, there is no condition around it - even
when every stream is copied rather than encoded, as this app always does. "preset"
is not a global FFmpeg option; it is a private option belonging to encoders such
as x264, and our build carried none of them, so FFmpeg refused the command before
doing anything.

Both output paths hit it: mp4 with "-c:v:0 copy -c:a:0 copy", and mp3.

x264 is now built and linked in. It is never actually asked to encode anything -
its assembly is disabled for that reason - it is there so the option exists and
means what the app thinks it means, which is also what the FFmpeg the desktop
build uses provides.

That makes the FFmpeg binary GPL rather than LGPL. It is built from source by a
workflow in a public repository, so the corresponding source is available, and
FFmpeg is invoked as a separate executable, so the app's own licence is unaffected.

Published under a new tag rather than replacing the old one, so it is always clear
which binary a given build was made against.

### Signing in collected cookies and then sent none

The failure context on a download read "Auth cookies: 0" immediately after a
successful login. Android's CookieManager answers a URL with the cookie header for
that URL and nothing else - no domain, no path, no expiry, because the platform API
exposes none of them. IsApplicableFor compares against Domain, so every captured
cookie was dropped on the floor.

A cookie read from a page is by definition applicable to that page, so the domain
it came from is now filled in where the platform left it empty.

### Not the cause, for the record

The 403s from YouTube were the mobile network, not the login: the same video
succeeds over Wi-Fi, and the cookie count above shows the requests were never
authenticated in the first place. YouTube binds stream URLs to the IP that asked
for them, and carrier NAT need not answer from the same one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
Removes the workflow that builds and releases this fork's APK, points the FFmpeg
download at the repository that would host it, and drops a diagnostic that was
only ever a way to debug a phone that could not be reached over adb.

---

Entfernt den Workflow, der das APK dieses Forks baut und veroeffentlicht, richtet
den FFmpeg-Download auf das Repository aus, das die Binaries bereitstellen wuerde,
und entfernt eine Diagnose, die nur dazu diente, ein per adb unerreichbares
Telefon zu untersuchen.

---

Everything removed here is specific to running this as a personal fork rather than
to Android support itself:

- `android-apk.yml` builds and signs this fork's APK for its own releases.
- `Download-FFmpeg-Android.ps1` pointed at this fork's FFmpeg release. It now names
  the upstream repository, which means the release has to be published once from
  the Actions tab before an Android build can fetch it - the script already fails
  with a clear message rather than packaging an HTML error page as if it were
  FFmpeg.
- The `--- Context ---` block appended to unexpected download errors listed the
  cookie count and the resolved FFmpeg path. It was invaluable while the only
  diagnostic channel was a screenshot of an error dialog, but it changes error
  text on every platform and has nothing to do with the port.

The tap-to-see-the-error behaviour it was found through stays: a tooltip needs a
pointer that hovers, and a touch screen has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB
@Tyrrrz

Tyrrrz commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Hi,

First of all, thank you for all the effort you put into this.

My main issue is that YoutubeDownloader is just not a project I can or want to devote a lot of focus anymore. I've been maintaining it for 9+ years and nowadays I prefer to shrink its scope rather than expand it. The main extent of my involvement is keeping YoutubeExplode alive (which powers YTD), mostly with the help of outside contributions.

So while an Android port is a valuable addition, I just cannot see myself taking responsibility for maintaining it. If somebody wants to, they can maintain their own Android port, either by using this project as a submodule or by wrapping YoutubeExplode in their own UI. If there are friction points preventing this from happening -- either in this repo, in YoutubeExplode, or FFmpegBin -- I'd be happy to look into it.

As it stands, I currently don't see any scope-expanding PRs such as this one getting merged, I'm sorry.

@Tyrrrz Tyrrrz added the enhancement New feature or request label Sep 15, 2026
@leobischof

Copy link
Copy Markdown
Author

Hi @Tyrrrz ,

Totally understandable, and thank you for being upfront and transparent about it. Maintaining an open-source project for 9+ years is a massive undertaking, and it makes complete sense to keep the scope focused rather than taking on the ongoing overhead of another platform.

I'll look into maintaining the Android version independently—either as a standalone UI wrapping YoutubeExplode or maintaining a separate fork.

If I hit any specific blockers or friction points in YoutubeExplode or FFmpegBin while doing that, I’ll reach out or open a focused issue there as you suggested.

Thanks again for all your work across the ecosystem!

@Tyrrrz

Tyrrrz commented Sep 20, 2026

Copy link
Copy Markdown
Owner

@leobischof thank you. Yes, please let me know if you encounter any integration issues.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants