Repository navigation
Add support for Android, continuing #682 - #894
leobischof wants to merge 33 commits into
Conversation
…ct, desktop project
…lems with release apk.
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
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
|
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. |
|
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! |
|
@leobischof thank you. Yes, please let me know if you encounter any integration issues. |
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 reworkeduntil 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
YoutubeDownloadermulti-targetsnet10.0;net10.0-android. Desktop-only properties andpackages are conditioned on
net10.0, so the desktop build is unchanged. A newYoutubeDownloader.Androidhead hosts the app;MainSingleViewis the chrome-less rootfor
ISingleViewApplicationLifetime, mirroringMainView.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.
AvaloniaMainActivityis no longergeneric; 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 projecttherefore sets
EmbedAssembliesIntoApkunconditionally.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 blockingnetwork read.
HttpClientdisposes a response on whichever thread theawaitresumed on,which for work started from a command is the UI thread, and StrictMode forbids network I/O
there:
The project sets
UseNativeHttpHandler=false, which is also the stack the desktop buildalready uses.
Android's picker returns a Storage Access Framework URI, not a path. Passing it on
produced
Read-only file system : '/content:'.DialogManagertranslates locations onthe 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
CookieManagerexposes onlyname=value. No domain, no path, no expiry —the platform API has no way to report them. Two consequences:
IsApplicableForcomparedagainst
Domainand silently discarded every captured sign-in cookie, andAuthSetupViewModel.IsAuthenticatedrequired an expiry in the future, which can neverhold 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, becausethe 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:So
.github/workflows/build-ffmpeg-android.ymlcross-compiles it, andDownload-FFmpeg-Android.ps1fetches what that publishes. The workflow has to be runonce 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:
-presetexists:
YoutubeExplode.Converterappends-preset <name>to every commandunconditionally, even when every stream is copied, and
presetis a private option ofan 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 neverasked 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.
contents — 16 KB page alignment, the encoders, and x264's option table — and says so
rather than implying it was run.
Known gaps
redesign.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HhgJfQirgVnRyUg6AD4kxB