Linthra has a native Flutter Linux target. This page is how to build and run it, what works today, and what deliberately does not yet.
Linux is not production-ready. This is the second milestone of issue #376: the app compiles, launches, renders in a real desktop window, and plays local and server audio. Desktop media controls and packaging are later milestones. Android is unaffected and remains the platform Linthra actually ships on.
linux/is committed, likeandroid/. Noflutter createstep.flutter build linuxproduces a runnable bundle.- The window carries Linthra's real identity: application id
io.github.thezupzup.linthra(the same reverse-DNS id as the Android build), window titleLinthra, a 1180×780 default size and a 420×600 minimum. - The window remembers its size, maximized state and (on X11) its position across restarts — see Window state.
- Every shared layer is the same code Android runs: domain models, providers, repositories, routing, theming, the local scanner, and the Jellyfin / Navidrome / Subsonic / Plex integrations.
- Server credentials persist in encrypted storage on Linux, through the desktop Secret Service (see Secure storage).
- CI builds the Linux target on every PR
(
linux-desktop-build.yml). For an official release, that same workflow is separately dispatched to build at the exact release tag, package the bundle into a.tar.gz, and attach it to the GitHub Release — see Release tarball below and docs/release-process.md §4a.
The Flutter Linux toolchain plus the native libraries Linthra's plugins need, for the native build described on this page.
Building the Flatpak instead? None of these packages are needed for it — it brings its own toolchain and bundles its own libmpv. See flatpak-development.md.
sudo dnf install \
clang cmake ninja-build pkgconf-pkg-config \
gtk3-devel xz-devel libsecret-devel mpv-libs mpv-develOn Kinoite (and any rpm-ostree system) do development work inside a toolbox rather than layering packages onto the host image:
toolbox create linthra
toolbox enter linthra
# then run the dnf command above inside the toolboxsudo apt install \
clang cmake ninja-build pkg-config \
libgtk-3-dev liblzma-dev libstdc++-12-dev libsecret-1-dev libmpv-devsudo pacman -S --needed clang cmake ninja pkgconf gtk3 xz libsecret mpv| Package | Needed by |
|---|---|
clang, cmake, ninja, pkg-config |
the Flutter Linux build itself |
| GTK 3 development headers | the Flutter Linux embedder |
liblzma / xz development headers |
Flutter tool prerequisite |
| libsecret development headers | flutter_secure_storage_linux — encrypted credential storage. Not optional: without it the plugin does not build, and Linthra never falls back to plaintext credentials. |
| libmpv | just_audio_media_kit / media_kit — local and HTTP(S) audio decoding, seeking, timing, and output through PulseAudio or PipeWire. Ubuntu's libmpv-dev, Fedora's mpv-libs + mpv-devel, and Arch's mpv provide it. |
At runtime the app additionally needs libmpv and wants a Secret Service provider
(gnome-keyring on GNOME, kwallet with its Secret Service interface on KDE).
Both are present on a standard Fedora Workstation or Kinoite install.
./scripts/setup_flutter.sh # the pinned Flutter (no sudo)
export PATH="$PWD/.tool/flutter/bin:$PATH"
flutter config --enable-linux-desktop
flutter pub get --enforce-lockfile
flutter run -d linux # or: flutter build linux --releaseA release build lands in build/linux/x64/release/bundle/; run
./build/linux/x64/release/bundle/linthra.
To run the same checks CI runs:
./scripts/verify_linux.shThat does pub get --enforce-lockfile, dart format --set-exit-if-changed,
flutter analyze, flutter test, the runner configuration check, the desktop
entry check (desktop-file-validate on
linux/packaging/io.github.thezupzup.linthra.desktop, skipped with a note if
desktop-file-utils is not installed), the same
native audio lifecycle smoke CI runs (builds and runs
tool/linux_audio_backend_smoke.dart), and flutter build linux --release.
It skips the smoke test and the build if the native packages above are
missing — including the libmpv runtime library, checked the same way
media_kit loads it, since a Linux build succeeds without libmpv but can't play
anything — and says which ones. scripts/verify_android.sh is unchanged and
still the Android twin.
sqlite3_flutter_libs compiles SQLite into the app so the Drift catalog runs on
the same engine and the same compile flags Android uses. On Linux its CMake
downloads the SQLite amalgamation from sqlite.org while configuring the
build. That is fine on a normal machine and impossible in a sandboxed or
air-gapped build — including flatpak-builder, which builds with networking
disabled.
linux/CMakeLists.txt therefore honours LINTHRA_SQLITE3_SOURCE_DIR: point it
at an already-unpacked amalgamation (a directory containing sqlite3.c) and
nothing is downloaded.
LINTHRA_SQLITE3_SOURCE_DIR=/path/to/sqlite-autoconf-XXXXXXX \
flutter build linux --releaseIt works through CMake's own FETCHCONTENT_SOURCE_DIR_<NAME> hook, so the
plugin is neither patched nor vendored — leave the variable unset and the build
is byte-for-byte the upstream one. scripts/check_linux_runner.py fails if the
seam is ever removed, because losing it breaks nothing on a machine with a
network and would only surface at packaging time.
flutter_secure_storage publishes a real Linux implementation
(flutter_secure_storage_linux), and Linthra uses it unchanged. On Linux it
stores through libsecret, i.e. the freedesktop Secret Service — the same
place your other desktop apps keep credentials — instead of Android's Keystore.
Jellyfin, Subsonic/Navidrome and Plex sessions are encrypted at rest on Linux
exactly as they are on Android.
What is stored: one entry per provider (jellyfin_session_v1,
subsonic_session_v1, plex_session_v1), each the JSON of a session object.
Passwords are never among them: Jellyfin exchanges one for an access token,
Subsonic derives a salt+token pair and discards it, Plex is token-based from
the start.
Three runtime facts worth knowing:
- A Secret Service provider must be running and unlocked. On a normal desktop session the login keyring is unlocked at login and nothing is asked of you. On a bare window manager with no keyring daemon, or with the keyring locked, reads and writes fail.
- A failure is reported, not swallowed. The three provider stores go through
one wrapper,
SecureSessionStorage(lib/data/repositories/secure_session_storage.dart), which turns a platform failure into a typedSecureStorageException(unavailable, locked, denied, unknown) carrying nothing from the platform error, so a token cannot reach a log or a diagnostics report through it. The provider settings cards turn that into a recoverable message ("Couldn't save your Jellyfin sign-in on this device. Unlock your keyring and try again."), the app stays usable, and a session that could not be saved is not adopted: it would look signed in until the next launch and then be gone. - Credential storage was not weakened to make Linux work, and Linthra has no plaintext fallback on any platform. A failed write stores nothing in preferences, the database, the cache, or any file Linthra writes.
In the Flatpak the same plugin reaches secure storage differently, and
without any D-Bus permission: libsecret detects the sandbox and, when the
desktop provides the xdg-desktop-portal Secret portal (GNOME via gnome-keyring,
KDE Plasma 6 via KWallet's ksecretd), keeps its own gcrypt-encrypted store
under the app's data directory with the master secret handed over by that
portal. Encrypted at rest either way, and libsecret's storage in both cases,
just not the shared keyring collection this page's native build uses. See
flatpak-development.md.
Linux uses just_audio_media_kit, which implements the same just_audio
platform contract as Android's engine but delegates decoding and output to
media_kit/libmpv. This was chosen over a second, parallel playback stack because
Linthra's existing JustAudioPlaybackController already owns the difficult
parts: ordered playable candidates, offline-to-stream fallback, queue mutation,
shuffle/repeat, completion, retries, position/duration state, ReplayGain, and
safe errors. LinuxPlaybackController only registers the Linux implementation;
Android still constructs JustAudioPlaybackController and therefore remains on
ExoPlayer, audio_service, and its existing audio-focus behavior.
The same resolved URI path handles regular filesystem files and direct or transcoded Jellyfin, Navidrome/Subsonic, and supported Plex HTTP(S) URLs. No source-specific player exists and credentials remain in the existing resolver.
Desktop needs its own volume control: there are no hardware volume keys bound to the app the way a phone has, and turning the whole system down to quieten one player is not the same thing. Now Playing (beside the action row) and the wide mini-player bar carry a mute button and a slider, with scroll-wheel and arrow-key adjustment; both are hidden on mobile, on windows too narrow to hold them, and while casting, where the receiver's own level is the Cast sheet's control.
The level lives on the playback seam, not in a widget: PlaybackController
gained setVolume / setMuted, and PlaybackState carries volume and
muted. So every way of changing it — either control, a shell's MPRIS slider,
the level restored at startup — moves the others, and nothing in the UI talks to
libmpv directly.
What the engine is actually set to is the listener's level times the current
track's ReplayGain (when normalization is on) times the audio-focus duck
factor, in JustAudioPlaybackController.engineVolumeFor. Automatic attenuation
therefore never moves the slider, and the slider never cancels a duck. Mute
keeps the level it was at, so unmute returns to exactly it.
The volume (never the mute) is persisted per install through
PlaybackVolumePersistence and restored at startup, sanitized to 0.0–1.0 on
both the way in and the way out — an out-of-range or non-finite stored value can
never reach the engine, and a launch is never silent for a reason nothing on
screen explains.
just_audio_media_kit is vendored under third_party/just_audio_media_kit
(and wired in through a dependency_overrides path entry) rather than pulled
from pub.dev. The local delta is two small additions:
JustAudioMediaKit.mpvProperties, an optional map of libmpv properties applied at player creation. Linthra uses it for the defaults below, and the headless CI smoke target layers its ownaoon top where there is no PipeWire/Pulse device.JustAudioMediaKit.livePlayers, a map of the media_kitPlayers that currently exist. just_audio's platform interface has no concept of an audio output device, and media_kit'sPlayeris private to the plugin, so this is how Audio output device reaches libmpv'saudio-device-listandaudio-device.
linuxMpvProperties in lib/core/services/linux_playback_controller.dart is
the one place these live, and resolveLinuxMpvProperties merges anything a
caller already set on top of them.
| Property | Value | Why |
|---|---|---|
cache-on-disk |
no |
media_kit turns mpv's on-disk demuxer cache on for every player ('cache-on-disk': 'yes', media_kit 1.2.6 lib/src/player/native/player/real.dart). That suits a video player buffering gigabytes; Linthra streams audio and manages its own offline downloads. Where mpv cannot create its temporary file — a sandbox, or a cache directory it cannot write — it logs [lavf] Failed to create cache temporary file. and [lavf] Failed to create file cache. on every stream (#405). |
Only the temporary on-disk packet file is turned off. media_kit's cache=yes
stays, so memory and network buffering behave as before, and Linthra's own
download/offline cache is a separate mechanism that this does not touch.
Ordering matters and is not accidental: media_kit applies its own defaults while
the player initializes, and just_audio_media_kit applies mpvProperties
afterwards through NativePlayer.setProperty, which awaits that initialization.
The Linthra values therefore land last.
third_party/just_audio_media_kit/PATCHES.md records the exact upstream
version and archive digest, what is and isn't vendored, and how to refresh it.
scripts/check_vendored_packages.sh (run by CI and by
scripts/verify_linux.sh) analyzes the package from its own directory with the
pinned toolchain and proves — offline — that the tree is still upstream plus
that recorded patch.
Settings → Music & playback → Audio output lists the outputs the host offers
(speakers, headset, HDMI, a USB DAC) and moves playback onto the one the
listener picks (issue #402).
Nothing goes around the backend: the list is libmpv's audio-device-list, and
the choice is its audio-device. Android is untouched — output routing there
belongs to the system, so the seam reports itself unsupported and the card is
not rendered at all.
| Piece | File |
|---|---|
| The seam | lib/core/services/audio_output_device_service.dart |
| Linux implementation | lib/core/services/linux_audio_output_device_service.dart |
| Platform split | lib/core/services/platform_audio_output_device_service.dart |
| Policy (restore, fallback, what is remembered) | lib/features/settings/playback/audio_output_controller.dart |
Four decisions worth knowing:
- Switching moves audio that is already playing. The chosen device is
written to every live player through media_kit's
setAudioDevice, and intoJustAudioMediaKit.mpvProperties, so a player the engine creates afterwards (a stop/start, a suspend/resume reload, a source switch) starts on it too. - A missing device falls back, quietly and safely. On launch the saved device is looked up in the list the host actually reports. If it is not there — unplugged headset, a different machine, a renamed sink — Linthra stays on the system default, forgets the stored value rather than pushing a name libmpv would reject, and the card says so.
- "Did not answer" is never read as "device gone". A backend that cannot be
enumerated — including one that does not publish
audio-device-listbefore the probe times out — clears nothing, re-routes nothing, and keeps the live selection; the card just reports that it found no outputs. libmpv seeds its own state with a loneautoentry, and treating that as the real list is exactly how a transient hiccup would look like an unplugged device, so the timeout is deliberately surfaced as a failure instead. - A refused switch is not recorded as done. Routing reports whether it took effect. If the backend refuses (the device went away between the list and the tap) nothing is stored, playback is still shown where it actually is, the card says the switch did not happen, and the next attempt at that device is a real attempt rather than a no-op.
- Choices are serialized. Two quick picks queue instead of racing, so the later gesture is the one that ends up playing and stored.
- Only stable ids are remembered. PipeWire/PulseAudio node names and ALSA
CARD=names are derived from the hardware and survive a reboot, so they are persisted. Numbers are not: ALSA'salsa/hw:1,0handles are card indexes that renumber when a USB DAC or dock is plugged in, and a bare numeric target (pipewire/42) is a runtime object id the daemon reuses for a different sink after a restart. The startup check can only ask whether a saved id still exists, and a reused number exists while meaning something else — so both are applied for the session and deliberately not stored, and the card explains that. - Launch never probes for nothing. Asking libmpv for its device list is the only slow part, so it happens when the Settings card is opened, or at launch only when there is a saved device to restore. When nothing is playing there is no player to ask, so enumeration builds a short-lived libmpv handle and disposes it — reading the device list never opens an output or makes a sound.
- The restore finishes before the first frame. media_kit builds its player
when the first track loads and reads the chosen output at construction, so a
restore still in flight then would play the opening seconds on the system
default before jumping. Bootstrap waits for it, bounded by a deadline
(
_audioOutputRestoreDeadline) so a wedged backend delays launch by that much and no more; past it the restore still lands and still moves live playback.
The mapping, the fallback and what gets remembered are covered by
test/core/models/audio_output_device_test.dart,
test/core/services/linux_audio_output_device_service_test.dart and
test/features/settings/playback/audio_output_controller_test.dart. What those
cannot cover is libmpv itself — flutter test runs on the Dart VM without the
native bundle, so a real Player is never built. Check that part on a real
desktop:
| Check | Expected |
|---|---|
| Open Settings → Music & playback with nothing playing | The list shows your real outputs, and no sound is produced while it enumerates. |
| Start a track, then switch output | The audio moves to the new device without the track restarting or losing its position. |
| Switch output, stop, play something else | The new track still comes out of the chosen device. |
| Plug in a headset, then press Refresh | The new device appears in the list. |
| Pick a USB output, quit, unplug it, relaunch | Playback uses the system default and the card says the saved output is unavailable. |
| Pick a USB output, quit, plug it back in, relaunch | Playback goes back to that output on its own. |
libmpv provides broad codec/container support and PulseAudio/PipeWire output.
It is a native runtime dependency, not a binary downloaded when Linthra starts.
The Flatpak manifest therefore builds libmpv as a declared module and bundles
it, so the packaged app needs no host libmpv at all
(flatpak-development.md).
media_kit_libs_linux normally offers an optional build-time mimalloc download;
Linthra explicitly disables it in linux/CMakeLists.txt, so this backend adds no
undeclared network access to an isolated flatpak-builder build.
Settings → Diagnostics & support → Linux playback builds a copyable report of what Linthra is playing through on this machine (issue #406). It is a separate card from the general Diagnostics one, shown on Linux only, so the Android page is unchanged.
A typical report:
Linthra Linux playback diagnostics
Backend: media_kit / libmpv (just_audio_media_kit)
libmpv: available
libmpv version: mpv 0.38.0
mpv audio-device: set
mpv cache-on-disk: no
Output selection: supported
Outputs found: 4
Selected output: usb, via pipewire
Selected output remembered: yes
Suspend/resume recovery: enabled
Playback state: playing
Recent playback failures: load ×2
| Piece | File |
|---|---|
| The snapshot + renderer | lib/core/diagnostics/linux_playback_diagnostics.dart |
| libmpv probe | lib/core/services/linux_mpv_probe.dart |
| Collector | lib/features/settings/diagnostics/linux_playback_diagnostics_collector.dart |
| The card | lib/features/settings/diagnostics/linux_playback_diagnostics_section.dart |
The report has no redaction pass, because there is nothing in the snapshot to
redact. Every field of LinuxPlaybackDiagnosticsData is one of four things: a
value from a closed enum, a bool, an int, or a version string that has already
been accepted by sanitizeVersion. There is deliberately no field for a stream
URL, a token, a header, a file path, a device node name, or a raw backend
error.
Three collection choices carry most of that:
- The output device is never named. libmpv's device id is
pipewire/alsa_output.usb-Topping_D10-00.analog-stereo, and a Bluetooth sink ispulse/bluez_output.AC_12_2F_…— the adapter's MAC. The report saysusb, via pipewire/bluetooth, via pulseinstead: the driver comes from the prefix, the kind from a fixed substring match, and both results are enum constants. The string that was classified is not kept. - Failures come from
SafeEventLog. Those entries are already fixed structural labels (load,resolution,timeout) written byStabilityDiagnostics, which has no parameter for a raw error. The report aggregates them to kind + count. The raw engine error — the one value that can carry a tokenized URL — is not collected anywhere, which is why it cannot leak. - mpv properties are an allowlist. Only
cache-on-disk,aoandaudio-devicemay appear;audio-deviceis shown assetrather than by value, and any value that is not a plain short token is shown assettoo. A property added to the map later is invisible here until someone decides it is safe to show.
sanitizeVersion rejects rather than strips: stripping the punctuation out
of mpv 1.0\nAuthorization: Bearer abc would leave the words behind, which is
worse than saying nothing, so a value that is not version-shaped is dropped and
its line omitted.
test/core/diagnostics/linux_playback_diagnostics_test.dart and
test/features/settings/diagnostics/linux_playback_diagnostics_collector_test.dart
pin all of that, including that a hostile device id or version string cannot
put a secret marker into the output.
Every optional line is emitted only when its value is known, and a failure is reported as one rather than dressed up as an answer. Three states are kept apart deliberately, because collapsing any pair of them hides the thing the report exists to show:
| Situation | Reported as |
|---|---|
| Nothing playing, so no player exists to ask | libmpv: not probed |
| A player answered | libmpv: available |
| A player exists and libmpv would not answer it (timeout, wedged handle) | libmpv: unavailable |
The same rule applies to the output list. A successful enumeration always
contains at least the system default, so an empty list can only mean the
backend did not answer — reported as Outputs found: unknown (the backend did not answer), never as Outputs found: 0, which would read as a machine with
no sound card. A list that was never asked for simply omits the line.
The card never fails to render because something was unavailable.
The libmpv probe deliberately never creates a player: it asks a live one
for mpv-version and gives up otherwise. A diagnostics view that spun up a
second libmpv handle would be the next bug report.
The pure parts (classification, sanitising, rendering, collection, the card)
run in flutter test. Two things do not, because they need libmpv actually
loaded:
| Check | Expected |
|---|---|
| Open the card with nothing playing | libmpv: not probed, and no sound is produced (no player is created to answer). |
| Start a track, then open the card | libmpv: available with a real version line, and the selected output's driver/kind matching the Audio output card. |
| Play something that fails (a server that is down), then open the card | The failure appears under Recent playback failures as a kind and a count, never as an error message or a URL. |
| Narrow the window to its 420 px minimum, or raise the desktop's text scale | The card's two actions stack instead of sharing a row, and the labels stay readable. |
| Area | State | Why |
|---|---|---|
| Audio playback | Supported | media_kit/libmpv through LinuxPlaybackController; local files and resolved Jellyfin, Navidrome/Subsonic, and Plex HTTP(S) streams share one backend. |
| Suspend / resume | Supported (app side); real device/sink timing varies | Lifecycle paused→resumed arms a bounded Linux-only reload of an actively playing track after a short backoff (issue #466). See Suspend / resume (manual matrix). |
| Light/Dark/System theme | Supported (app side); the native brightness bridge itself is Flutter's, not independently verified here | Settings → Appearance's System/Light/Dark choice (issue #459) is the same shared ThemeModePreference/ThemeModeController Android uses, mapped onto MaterialApp's own themeMode — no gsettings/D-Bus/GNOME/KDE-specific code in Linthra itself, and no separate Linux theme path (test/app/theme_mode_test.dart proves that). Supplying System's brightness on Linux is Flutter's GTK embedder (via the XDG desktop portal or a GNOME GSettings fallback); that native bridge is outside Linthra's code and isn't exercised by flutter test, which runs on the Dart VM and injects brightness straight into Flutter's test PlatformDispatcher. Reproducing the real bridge deterministically in CI would need a running portal daemon or GNOME schemas — exactly the DE-specific setup this app avoids adding — so it stays untested here and is a known gap, not a claimed guarantee. |
| Media session / MPRIS | Supported | PlatformMediaSessionBinding routes Linux to MprisMediaSessionBinding, which exports /org/mpris/MediaPlayer2 and owns org.mpris.MediaPlayer2.linthra (issue #397). Shells get PlaybackStatus, Metadata, Position and the transport methods; media keys work through the same interface. Volume is read/write, so a shell's own volume slider drives Linthra's level (and reads zero while muted); Rate stays honestly read-only. Raise and Quit are answered too, so a listener whose window is hidden by background mode can bring Linthra back or shut it down from the shell's media widget (#401). audio_service is still never initialised on Linux — it stays the Android delegate. A machine with no session bus simply gets no desktop controls. |
| Close-window behaviour | Supported | Settings → Music & playback → Desktop window chooses between quitting and keeping playback running (issue #401). The runner answers the close, Dart decides what the answer should be, and background mode only ever starts while audio is actually playing. See Closing the window. |
| Android Auto | Android-only, by design | It is an Android platform integration, not a Linthra feature. |
Media notification + POST_NOTIFICATIONS |
Android-only, by design | There is no equivalent gate on Linux; desktop controls come from MPRIS instead. Standalone track-change notifications are issue #400. |
| Android audio focus | Android-only, by design | JustAudioPlaybackController already scopes its focus handling to Android/iOS. |
SAF (content:// folders) |
Android-only, by design | Linux picks a real filesystem path. The scanner's desktop path is the one that runs. |
| Folder chooser | Supported | Linthra's own runner channel (linux/runner/folder_picker_channel.cc) opens GtkFileChooserNative: the ordinary GTK dialog natively, and the xdg-desktop-portal chooser inside the Flatpak, where file_picker's zenity/kdialog do not exist (issue #438). scripts/check_linux_runner.py holds the runner's channel name to the Dart side's. |
| Multiple music folders | Supported | Settings → Local music takes several folders and scans them as one library (issue #412). A folder selected inside another one is walked once, removing a folder removes only its tracks, and an existing single-folder selection carries over as the first folder. Android keeps its single SAF/MediaStore selection. |
| Lost folder access | Supported | A selected folder that stops resolving (unmounted drive, deleted folder, revoked portal document) is reported as a recoverable "select it again" state on that folder's row of the Local music card, and the indexed catalog is left alone rather than replaced with an empty scan. With several folders, the readable ones still refresh while the unreachable one keeps the tracks it already contributed. |
| Local tag reading | Supported | FilesystemLocalMetadataReader reads title, artist, album artist, album, track number and duration from ID3, Vorbis comments, MP4 atoms, APEv2 and RIFF INFO through audio_metadata_reader (issue #407). An unreadable or untagged file still appears, from its filename. Android is deliberately unchanged: its tags come from the native SAF walk. |
| Local embedded artwork | Unsupported | Tags are read without pulling cover images out of every file during a scan. Extracting and caching embedded art on desktop is issue #408; tracks keep the placeholder until then. |
| Audio output device | Supported | Settings → Music & playback → Audio output lists libmpv's audio-device-list and routes playback with audio-device (issue #402). A saved device is re-applied at launch, and one that is no longer present falls back to the system default. See Audio output device. |
| Playback diagnostics | Supported | Settings → Diagnostics & support → Linux playback builds a copyable report of the backend, libmpv, the selected output subsystem and recent failure kinds (issue #406). Safe by construction: no field can hold a URL, token, header, path, device name or raw error. See Playback diagnostics. |
| Chromecast | Android/iOS only | Already gated in cast_providers.dart; Linux keeps the honest "cast unavailable" service. |
| Share sheet, launcher-icon switching | Android-only, by design | No desktop equivalent; the UI simply omits them. |
| Volume control | Supported | A mute and a slider on Now Playing and the wide mini-player bar, plus MPRIS Volume, driven through PlaybackController and remembered across launches (issue #394). See Volume. |
| Desktop layout | Supported | The shell swaps its bottom bar for a navigation rail at 900 px, and feature screens adapt on the width they are given — including a third pane for the Library grids and for Now Playing's queue. See How the desktop layout adapts. |
| Window geometry | Supported | Size and maximized state survive a restart; position too, on X11. A saved position is re-checked against the monitors attached now. See Window state. |
| Content density | Supported | Compact by default, switchable to Comfortable in Settings → Appearance and remembered across restarts (issue #395). Both are Material VisualDensity values, so the choice reaches every list row, grid and control at once. Touch builds are unaffected. See Density (Compact / Comfortable). |
| Pointer affordances | Supported | Compact content density, visible hover feedback, right-click context menus with a keyboard equivalent, and Ctrl/Shift multi-select in track lists — all keyed on the input rather than the window width. See Pointer, not width. |
| Keyboard shortcuts | Partial | Quick search is bound to Ctrl+K / Ctrl+F (issue #393) — see Quick search. The volume control takes the wheel and arrow keys when focused; global transport and volume shortcuts are still later work in #376. |
Nothing in that table is faked. Each one is an explicit implementation behind an
existing interface, so it is visible in the code and covered by tests — with
one caveat: the Light/Dark/System theme row's app-side wiring is covered, but
the native GTK/portal brightness bridge underneath it is Flutter's own
responsibility and is not, and cannot easily be, exercised by flutter test;
see that row for why.
Linthra picks platform implementations behind interfaces, never with a
Platform.isLinux check inside a widget. Two pieces make that testable:
HostPlatform(lib/core/platform/host_platform.dart) — the platform as a value rather than adart:ioread. Production usesHostPlatform.current; tests passHostPlatform.androidorHostPlatform.linux, so both branches of every seam can be asserted from one machine.hostPlatformProvider(lib/data/repositories/host_platform_provider.dart) — the same value for provider-level selection, overridable in aProviderContainer.
The seams that branch on it:
| Seam | Android | Linux |
|---|---|---|
PlatformMediaSessionBinding |
audio_service session |
inert, never touches audio_service |
localPlaybackControllerProvider |
JustAudioPlaybackController (ExoPlayer) |
LinuxPlaybackController (media_kit/libmpv) |
PlatformFolderPickerService |
SAF tree picker | runner GTK chooser (GtkFileChooserNative), which GTK routes to xdg-desktop-portal inside the Flatpak; file_picker only as a fallback |
PlatformAudioFileScanner |
SAF content-resolver walk | dart:io walk |
safDocumentListerProvider |
native content resolver | unsupported |
safPermissionProbeProvider |
native grant probe | unsupported |
localLyricsReaderProvider |
SAF sibling document | filesystem sibling |
PlatformShareService |
ACTION_SEND |
no-op |
PlatformLauncherIconService |
<activity-alias> toggle |
no-op |
Adding a platform-specific behaviour means adding a row here, not a check in a widget.
Presentation is the one thing that does not branch on HostPlatform. Layout
adapts on the width a widget is actually given, so the same window is laid
out the same way wherever it runs, and a Linux window narrowed to 600 px gets
the phone layout rather than a cramped desktop one.
lib/shared/layout/adaptive_layout.dart holds the whole vocabulary:
| Piece | What it does |
|---|---|
WindowSizeClass |
compact (< 600), medium (< 1000), expanded (< 1600), large — Material's window size classes, with the phone thresholds left where Android already behaves |
windowSizeClassFor(width) |
the pure breakpoint function, so the thresholds are unit-testable |
AdaptiveLayoutBuilder |
resolves the class from the widget's own BoxConstraints — inside the desktop shell a screen is narrower than the window by the navigation rail, and a pane is narrower still |
AdaptiveContentWidth |
caps and centres a single column (maxContentWidth, or maxFormWidth for settings), a no-op below the cap |
lib/shared/layout/pane_layout.dart holds the compositions built out of those:
| Piece | What it does |
|---|---|
SplitPanes |
two panes with a hairline between them, one at a fixed width and one taking the rest, capped and centred — the shape a header pane beside a scrolling list and a grid beside a detail both want, written once |
ListDetailPanes |
a list or grid that gains a detail pane once its own box is wide enough, and tells the list which mode it is in so a tap can select into the pane or push a route |
DetailPanePlaceholder |
the calm "nothing picked yet" state, so the pane holds its width instead of making the grid reflow on every click |
What it buys, per surface:
- Album grid — cards stay between 200 and 260 logical px and the grid adds columns instead of inflating covers: 5 across at 1280, 7 at 1920, 10 at 2560, where before every desktop width got the same six mobile-sized cards.
- Artists — the same rows flow into 2–5 columns rather than one row per monitor width.
- Songs, playlists, downloads, settings — one column, capped and centred, so a title and its trailing action never end up a screen apart.
- Album and artist detail — at
expandedand up, a persistent left pane (cover/portrait, counts, Play and Shuffle) beside the scrolling track list. Selection mode falls back to the single column. - Albums and Artists, three panes — past
listDetailMinWidththe grid keeps its place and the album (or artist) opens beside it, so browsing a shelf is a click each rather than a click and a trip back. With the shell's navigation rail that is navigation · content · detail. Below the split width the same screen is pushed as a route, exactly as before. The selection lives on the Library screen rather than in the pane, so dragging a window across the threshold moves the detail between a pane and a page without resetting it. - Now Playing — at
expandedand up, the cover sits beside the metadata and transport, and lyrics open next to the cover instead of replacing it. Same_showLyricsstate and same playback state as the stacked layout. Wider still, the queue joins them as a third column instead of a sheet over the top, so lyrics and up-next are readable at once; the action row's queue button becomes the pane toggle, and narrower windows keep the sheet. - The now-playing bar — on a desktop host the progress line along its top
edge is a seek control, not a readout: the same
PlaybackProgressBarthe full player uses, at its compact density and without the time caption. Width alone does not promote it — a tablet in landscape is as wide as a desktop window and still driven by a thumb.
Both breakpoints in the app agree by construction: the shell swaps its bottom
bar for the navigation rail at 900 px of window, and a feature screen inside it
only reaches expanded once the space left over is 1000 px wide.
Three things adapt on the input rather than on the window, because that is what they are actually about. A tablet in landscape is as wide as a desktop window and is still driven by a thumb.
- Content density — the theme uses
VisualDensity.adaptivePlatformDensity, Material's own adaptive value: compact on Linux/macOS/Windows, standard everywhere touch-first. The two places that sized rows with a hard-coded number follow it rather than ignoring it — the songs list's fixed row extent (which the A–Z index measures its scroll offsets in) and the artist grid's cell floor — and both only give back padding, never room the words need. On desktop the value is also a preference: see Density (Compact / Comfortable).
Settings → Appearance → Density offers two desktop densities (issue #395):
| Mode | Material value | Effect |
|---|---|---|
| Compact (default) | VisualDensity.compact |
What a desktop build has always looked like. Fits the most of a library per screen. |
| Comfortable | VisualDensity.comfortable |
One step back towards the touch layout: roomier rows and grids, still denser than a phone. |
The preference is only a choice between two of Material's own
VisualDensity values — there is no second spacing scale. That is what makes
it reach the whole app for free: every widget that already reads
Theme.of(context).visualDensity (list rows, buttons, popup menus, the songs
list's row extent, the artist grid's cell floor) follows it with no change of
its own, and Material's minimum tap targets still apply on top, so neither
setting can shrink a control below a real click target.
| Piece | File |
|---|---|
| The preference | lib/core/models/desktop_density.dart |
| Store seam | lib/core/repositories/desktop_density_store.dart |
| Persistence | lib/data/repositories/shared_preferences_desktop_density_store.dart |
Controller + VisualDensity mapping |
lib/features/appearance/desktop_density_controller.dart |
| The card | lib/features/appearance/desktop_density_card.dart |
Two rules keep touch out of it. The card renders nothing off desktop, and
LinthraApp passes a density to AppTheme only on a desktop host — off
desktop the parameter is null and the theme keeps
VisualDensity.adaptivePlatformDensity exactly as before. An Android build can
therefore never inherit a desktop density, which
test/features/appearance/desktop_density_widget_test.dart pins.
Like the theme mode, the stored value is read before runApp so the first
frame already lays out at the chosen density; changing it afterwards rebuilds
both themes and relayouts every screen at once, with no restart.
- Hover —
hoverColorcarries enough weight to be seen on a black-first theme, and every ink surface picks it up at once: list rows, buttons, grid cards, rail destinations, menu items. It is neutral rather than brand-tinted, because the violet tint is what selected means.HoverHighlight/HoverArtworkVeilinshared/widgetscover the one case ink cannot: an album cover hides the overlay painted on the Material behind it. - Right-click and modifier-click —
ContextMenuRegionopens a surface's existing menu on secondary click and on the keyboard's menu key (or Shift+F10), andTrackSelectionreads Ctrl/Cmd and Shift off the hardware keyboard at tap time. Nothing is gated on a platform, so a keyboard case on a tablet gets both for free and a bare touch tap is unchanged.
Right-clicking a row opens the list that surface already had, built at open time so it reflects the state as it is then. Track rows share one list and one dispatcher with their 3-dot button, so a right-click cannot offer an action a tap cannot; they also gain Show album and Show artist, routed through the same derived ids the grids use and offered only where the tags give somewhere to go. Albums and artists get Play · Shuffle · Play next · Add to queue · Add to playlist, each a command their detail pages already call. Playlists reuse their rename/delete pair.
Multi-select follows the rules every desktop list has. Ctrl-click (Cmd on macOS)
picks one row out and starts a selection when there is none; Shift-click takes
everything between. TrackSelection (lib/features/library/track_selection.dart)
owns both, keyed by the provider-namespaced uri so two providers' same-id copies
can never be selected together, and it resolves a bulk action against the list
as shown — so a row that a search or a removal has taken off screen can never
be acted on. A range is computed over the list the clicked row is in, which for
the songs tab is the A–Z view's own sorted order rather than whatever the screen
handed it. Starting a selection hands the screen the keyboard, so Escape leaves
it. Long-press still does what it always did, so nothing about a phone changes.
Ctrl+K (or Ctrl+F) opens a quick-search overlay over whatever is on screen: one box across songs, albums, artists and playlists, grouped and fully keyboard-driven (↑/↓ to move, Enter to open, Esc to close). The screen underneath keeps its state — it is a dialog on the root navigator, not a navigation — and opening a result goes through the app's existing routes and playback actions.
Like the layout, it is not gated on HostPlatform: the binding
(quick_search_shortcuts.dart) wraps
the router, above every route, and can only fire when a real keyboard sends the
chord — so a phone is unaffected while an Android tablet with a keyboard case
gets it for free. What it searches and how it ranks is documented in
library.md.
Tests: test/shared/layout/adaptive_layout_test.dart,
test/features/library/album_grid_test.dart,
test/features/library/artist_grid_test.dart,
test/features/library/detail_desktop_layout_test.dart,
test/features/player/player_desktop_layout_test.dart — each covers the phone
width alongside 1280, 1920, 2560 and ultrawide, so a change that only looks
right on one monitor fails.
The window opens where you left it. linux/runner/window_state_store.cc writes
$XDG_CONFIG_HOME/io.github.thezupzup.linthra/window-state on shutdown and
reads it back before the window is shown, so a restored window is drawn at its
remembered size on the first frame rather than resizing in front of you.
Three things about it are worth knowing:
- The size saved is the unmaximized one. GTK 3 will not tell you that after
the fact —
gtk_window_get_size()on a maximized window returns the maximized size — so the store tracks the last size the window had while it was an ordinary window, and saves that alongside the maximized flag. Un-maximizing after a restart lands back where you left it. - Position is X11-only. Wayland gives a client neither its own position nor a way to set one. Under Wayland the file holds a size and no position, and the compositor places the window, which is what Wayland users expect rather than something to work around.
- A saved position is re-checked against the monitors that exist now. Unplug the monitor a window was on and the position is dropped rather than restored off-screen; so is one whose title bar would land above the work area, where no mouse could reach it. The size survives either way — losing a monitor should not also lose the window's shape.
The rules behind all of that — what a saved geometry means, when it is too
damaged to trust, whether a position is still reachable — live in
native/linthra_desktop, which is pure C++ with no
GTK, no GDK and no filesystem in it. The runner links those sources directly and
does the toolkit half; the same sources build and test on their own, without a
display server or a second monitor to unplug, in
cpp-desktop-window.yml.
A damaged or missing file is never fatal: it falls back to the 1180×780 default, clamped to the 420×600 minimum. Deleting the file resets the window.
It composes with background mode: closing a window that keeps playing hides it rather than destroying it, so the geometry stays tracked and a launcher click gets the same window back at the same size. The file is written when the application actually shuts down, whichever way it got there.
scripts/check_linux_runner.py— the committed runner still matches the app's identity (APPLICATION_ID= Android'sapplicationId,BINARY_NAME= the package name, window title =AppInfo.name), it still makes the four desktop-identity calls below in the places where GTK honours them, the window metrics are sane, the offline SQLite seam is wired, the folder-picker and window-lifecycle channels still agree with their Dart halves, the application is still registered single-instance, and nothing underlinux/hardcodes an absolute host path. Tests:test/tooling/check_linux_runner_test.py.scripts/flatpak_launch_smoke.sh— launches the packaged Flatpak twice and readsWM_CLASSand_NET_WM_ICONback off the real window each time, so a window that stops answering to the application id fails CI.test/app/linux_startup_test.dart— every providermain()reads before the first frame constructs on a Linux host, with no Android MethodChannel binding among them.test/features/library/library_platform_bindings_test.dartandtest/features/player/playback_platform_binding_test.dart— each seam picks the Android implementation on Android and the desktop one on Linux.
linux/CMakeLists.txt and linux/runner/my_application.cc are listed as
unmanaged_files in .metadata, so flutter migrate leaves Linthra's edits
alone. If someone re-runs flutter create --platforms=linux . anyway, the
checker above is what catches the reverted title and the lost SQLite seam.
Everything Linthra installs on Linux is named io.github.thezupzup.linthra: the
desktop entry, the icon, the AppStream component, the Flatpak. The running
window only joins them if the runner says so, and each display server reads a
different thing, so linux/runner/my_application.cc sets all of them from
APPLICATION_ID (never from a literal):
| Call | Where | What reads it |
|---|---|---|
g_set_prgname(APPLICATION_ID) |
my_application_new(), before gtk_init() |
GTK 3 sends this as the Wayland xdg_toplevel app id, and as the instance half of X11's WM_CLASS |
gdk_set_program_class(APPLICATION_ID) |
my_application_startup(), after the chain-up |
the class half of WM_CLASS. GDK's default is the program name with its first letter upper-cased, which is not the app id |
gtk_window_set_default_icon_name(APPLICATION_ID) |
same | GTK attaches the themed icon as _NET_WM_ICON; without it the window has no icon of its own |
g_set_application_name(AppInfo.name) |
same | g_get_application_name(), which GTK and portals show to the user |
The two in my_application_startup() have to run after the chain-up to
GtkApplication::startup: that is what calls gtk_init(), and gtk_init()
resets GDK's program class unconditionally. Set earlier, they compile, run, and
are thrown away.
linux/packaging/io.github.thezupzup.linthra.desktop declares the same X11 pair
as StartupWMClass=, so a shell matches the window to the entry exactly rather
than falling back to lower-casing whatever class it finds.
One more thing decides whether the icon appears at all: the <svg root element
has to start within the first 256 bytes of tool/branding/linthra_icon.svg.
An SVG has no magic number, so content sniffing looks for that literal string and
gives up after 256 bytes. Push it past that (a licence header, a DOCTYPE, a
descriptive comment between the XML declaration and the root tag) and gdk-pixbuf
refuses the file outright: GTK cannot load it as a themed icon, and every
launcher that resolves icons through that stack shows a generic one. The file
still parses, still validates, and still renders in a browser, which is why the
checker measures the offset. Put comments inside the root element.
System sleep and wake are only partly visible to Flutter: the embedder usually
delivers AppLifecycleState.paused → resumed, but audio devices (PulseAudio /
PipeWire, Bluetooth sinks) and the network often come back later than that
signal. Linthra therefore:
- arms recovery only on
paused(not briefinactivedialogs); - on Linux, after resume, waits a short backoff then re-resolves and reloads the current track on the same engine when playback was active across suspend — never a second player, so audio cannot duplicate;
- leaves a user-paused track paused;
- surfaces the existing error + Retry UI when recovery fails;
- does not enable this path on Android (screen-on must never auto-restart).
Automated coverage: test/core/services/linux_suspend_resume_recovery_test.dart
and the ActivePlaybackController lifecycle tests. Re-check on a real machine
before a Linux milestone release:
| Scenario | While… | Expect after wake |
|---|---|---|
| Lid close / Sleep | Playing a local file | Same track/queue; audio returns near the prior position; no second stream |
| Lid close / Sleep | Playing a remote (Jellyfin / Navidrome / Plex) stream | Fresh stream URL; same logical track/queue; Reconnecting… then playing, or error + Retry if the server is still down |
| Lid close / Sleep | Paused | Still paused; pressing play resumes the same track |
| Sleep with Bluetooth headphones offline | Playing | Recoverable error or success once the sink returns; never hung forever |
| Sleep with network offline | Playing remote | Bounded recovery; Retry works when connectivity returns |
| Minimize only (no sleep) | Playing | App stays responsive; no crash; ideally continues without a full reload |
| Repeat sleep/wake 3× | Playing | Still one coherent queue/position; no growing listener/service leak; UI stays usable |
By default, closing the window quits Linthra, which is what it has always done. Settings → Music & playback → Desktop window offers the other choice: keep playing in the background, where a close hides the window and leaves playback and the desktop media controls running.
The decision is split across the two halves on purpose:
- Dart decides.
DesktopClosePolicycombines the stored preference with the live playback state, andDesktopWindowLifecycleServicepushes the one resulting boolean to the runner whenever either changes. - The runner applies. A GTK
delete-eventhas to be answered synchronously, solinux/runner/window_lifecycle_channel.cccannot ask anything: it already holds the answer, and either hides the window or lets GTK destroy it.
What that buys, in the order the requirements ask for it:
- No hidden zombie process. Background mode only takes effect while audio is playing (or loading, buffering, reconnecting). Close it while paused, stopped or idle and Linthra quits, whatever the preference says.
- Nothing extra is started for it. Hiding the window starts no background service and no second process: it is the same running app with its window away, so the audio engine, the queue and the MPRIS export carry on and the window simply stops drawing. Linux has no foreground-service concept to hold the way Android does.
- A hidden Linthra ends itself. When the queue runs out, the app quits on its own rather than sitting invisibly in the process list. A pause keeps it alive, because with no window on screen a shell's media widget is the only way back to playing.
- A clear way to fully quit. "Quit Linthra now" sits in the same settings
card, and MPRIS
Quitdoes the same thing from the desktop's media controls. Both run the app's graceful shutdown (stop playback, release the audio engine, give back the MPRIS bus name, close the database) before the process ends, rather than leaving it to whatever time the engine gets on the way down. - No duplicate instance. The runner is single-instance, so launching
Linthra while it is already running (from the launcher, a terminal, or a
desktop file) reaches the running process as an activation and presents the
window it already has, hidden or not. Two processes would mean two audio
engines, two MPRIS names and two connections to the same SQLite catalog.
scripts/check_linux_runner.pyfails if the runner goes back toG_APPLICATION_NON_UNIQUE.
One deliberate interaction with suspend / resume: while the window is hidden, Linthra does not arm the post-suspend reload. A hide looks exactly like a system suspend from the lifecycle observer's side, and reloading a track the listener never stopped would be an audible skip. The trade is that a real machine suspend taken while the window is hidden is not recovered from either; it is recovered the next time the window comes back.
Automated coverage: test/core/lifecycle/desktop_close_policy_test.dart,
test/core/services/desktop_window_lifecycle_service_test.dart,
test/core/services/method_channel_linux_window_test.dart,
test/features/settings/desktop/desktop_window_section_test.dart, and the
runner contract in test/tooling/check_linux_runner_test.py. What only a real
desktop can answer, to re-check before a Linux milestone release:
| Scenario | Setting | Expect |
|---|---|---|
| Close the window while a track plays | Keep playing | Window disappears, audio continues, media controls still work |
| Click the launcher again while hidden | Keep playing | The same window comes back; one process, one entry in the media controls |
| Raise from the shell's media widget | Keep playing | Same as above |
| Let the queue finish while hidden | Keep playing | Linthra exits on its own; the MPRIS entry disappears from the shell |
| Pause from the media widget while hidden, then play | Keep playing | Still there, resumes the same track |
| Close the window while paused | Keep playing | Linthra quits; nothing is left running |
| Close the window while a track plays | Quit | Linthra quits, audio stops |
| Quit from the media widget, or "Quit Linthra now" | Either | Playback stops, the window goes, the MPRIS name is released |
| Reopen after any quit | Either | Normal cold start; the crash-safe session restores paused, as it always did |
On Linux, Linthra persists a small logical playback session while a track is
queued — provider-namespaced track ids (jellyfin:…), local paths, shuffle/
repeat modes, and position. It never writes authenticated stream URLs or
provider tokens. After an unexpected process exit, the next launch restores that
queue as paused (never autoplay); pressing play re-resolves remote tracks
through the normal signed-in provider path. Missing local files, signed-out
providers, and wrong-version/corrupt records drop only the invalid rows (or the
whole session) and never block startup.
Automated coverage: test/core/models/persisted_playback_session_test.dart,
test/data/repositories/shared_preferences_playback_session_store_test.dart,
test/core/services/playback_session_persistence_test.dart.
Every official release gets Linthra-<tag>-linux-x64.tar.gz attached to its
GitHub Release automatically — e.g. Linthra-v0.1.15-linux-x64.tar.gz for
tag v0.1.15. This isn't wired to a tag push or a Release-publish event
(Linthra's release automation creates both using its own GITHUB_TOKEN,
and GitHub generally doesn't start new workflow runs from GITHUB_TOKEN-
authored events); instead publish-stable-release.yml (for stable releases)
and android-release-build.yml (for a directly-pushed alpha/beta/rc tag)
explicitly dispatch linux-desktop-build.yml at the exact release tag once
the Release exists. The archive is exactly flutter build linux --release's
output (build/linux/x64/release/bundle/) at that tag, tarred with the
bundle contents (linthra, lib/, data/, …) at the archive root:
tar -xzf Linthra-v0.1.15-linux-x64.tar.gz
./Linthra-v0.1.15-linux-x64/linthraThis is the native Linux build, not a self-contained package. It still needs the runtime libraries in Required packages — libmpv and GTK 3 in particular — already installed on the machine running it, and a Secret Service provider for secure storage. It is not distro-independent. The eventual Flatpak is the self-contained, sandboxed distribution target; this tarball is a plain native build for anyone who already has the runtime dependencies on hand, and it is separate work with no bearing on the Flatpak's design.
See docs/release-process.md §4a for exactly how the CI job builds and attaches it.
The Linux distribution target is Flathub. A locally installable Flatpak on Fedora Kinoite is a validation step on the way, not the destination.
That packaging is now underway in #376 and lives outside this page: the
committed manifest is in flatpak/ and the contributor
workflow for building, installing and debugging it on Fedora Atomic is
flatpak-development.md. It builds, installs and
launches with working audio, and installs the desktop entry
(linux/packaging/io.github.thezupzup.linthra.desktop, shared with any future
native package rather than Flatpak-only) together with Linthra's icon under
share/icons/hicolor/ (the scalable SVG launchers resolve, plus the fixed-size
PNGs GTK needs for the window's own _NET_WM_ICON) and AppStream metainfo
under
linux/packaging/io.github.thezupzup.linthra.metainfo.xml; networking is
granted with one narrow permission, secure storage needs none (it goes through
the desktop's Secret portal), and the remaining filesystem-permission audit is
still an open sub-issue.
None of it changes the native build on this page.
Decisions already taken with that destination in mind:
- one reverse-DNS application id shared with Android, stable and
Flathub-shaped (
io.github.thezupzup.linthra); - no build-time dependency on host filesystem paths (enforced by the checker);
- no runtime downloading of anything that belongs in the build;
- the SQLite escape hatch above, so a network-isolated build is already possible;
- every platform integration behind an interface, so a sandboxed portal-based implementation can be slotted in without touching feature code.