Repository navigation
Modules Media
Print the name of the currently playing song
| Module type | media |
| Default order | 48 (only used by --gen-config) |
| Module source | src/modules/media/media.c |
| Detection source | src/detection/media/ |
Prints what the system's now-playing interface reports: the artist, the song title, the elapsed and total time, and the playback status.
Media: Daft Punk - Get Lucky - 01:23 / 06:07 (22%) [Playing]
The artist is dropped when the same text already appears in the song title (the comparison ignores
case, spaces, - and .), and both fields are cleaned of the usual YouTube decorations before being
printed. When no media session is found the module reports No media found and prints nothing — and
because display.showErrors defaults to false, the line simply disappears.
| Platform | Implementation | Notes |
|---|---|---|
| Linux | media_linux.c |
MPRIS over the session DBus |
| FreeBSD / OpenBSD / NetBSD / GNU/Hurd | media_linux.c |
Same code; needs DBus |
| Windows | media_windows.cpp |
WinRT GlobalSystemMediaTransportControlsSessionManager
|
| macOS | media_apple.m |
MediaRemote, or a Perl helper process on macOS 15.4+ |
| Android | media_android.c |
media_session over /dev/binder while SurfaceFlinger is the display server, otherwise MPRIS over the session DBus |
| Solaris / illumos | none | No detection source is referenced |
| Haiku | none | No detection source is referenced |
Only the four backends above exist. On a platform without one the module reports
No media found rather than "not supported".
| Key | Type | Default | Description |
|---|---|---|---|
percent |
object | { "green": 100, "yellow": 100, "type": 0 } |
Colour thresholds and style for the percentage. type: 0 means "use display.percentType". |
key |
string | Media |
Module key. A single space hides the key and the separator. |
keyColor |
color | – | Overrides display.color.keys
|
keyIcon |
string | built-in glyph | Printed when display.key.type includes the icon bit. Any glyph works; "" prints none. |
keyWidth |
integer | – | Overrides display.key.width
|
outputColor |
color | – | Overrides display.color.output
|
format |
string | – | Custom output format (see below) |
condition |
object | – | Show the module only if the conditions match |
The player the data is taken from is not a module option: general.playerName (the
--player-name command-line flag) selects it, and the same setting is used by the player module.
On Linux it is an MPRIS bus suffix (spotify, vlc, …) or a full org.mpris.MediaPlayer2.… name;
on Windows it is matched case-insensitively as a substring of the source app
(AppUserModelId). With no playerName, Linux tries spotify, vlc and
plasma-browser-integration in that order and then walks the bus name list; Windows and macOS ask the
system for the current session.
percent.type defaults to 0, which means the module follows display.percentType
(num | num-color out of the box) — (22%). Setting it to a non-zero bit mask overrides the global
value for this module only; hide-others removes the time from the default line, bar adds a bar.
Run fastfetch -h media-format for the authoritative list.
| Variable | Description |
|---|---|
{combined} |
The cleaned song title — the title with the decorations removed |
{title} |
The raw song title as the backend reported it |
{artist} |
The raw artist as the backend reported it |
{album} |
The album title |
{status} |
Playing, Paused, Stopped, … |
{progress} |
Elapsed / total, e.g. 01:23 / 06:07
|
{progress-num} |
Percentage, e.g. 22% — no parentheses |
{progress-bar} |
Percentage as a bar |
{player-name} |
Friendly player name, e.g. Google Chrome
|
{player-id} |
Bus name (Linux) or bundle / app id (macOS, Windows) |
{url} |
Media URL, when the backend provides one |
{title} is the untouched backend string while {combined} is the cleaned one: only {combined}
has (Official Music Video), [Lyrics] and the like stripped. {artist} is likewise the raw value —
the "already in the title" test and the - Topic / VEVO trimming happen only in the default line.
The three {progress*} variables are empty when the backend reports no length, and {progress-num} /
{progress-bar} also depend on percent.type (they are empty when the corresponding bit is off).
When nothing is playing the module carries an error object instead:
[ { "type": "Media", "error": "No media found" } ]-
lengthandpositionare milliseconds.positionis extrapolated from the last update and the playback rate while the track is playing, so it advances even if the player only reports its timeline on seek. -
song.name,song.artistandsong.albumare the raw backend strings, i.e. the same values as{title}/{artist}/{album}, not the cleaned default-line text. -
coveris the path of the artwork file, ornull. See the pitfalls below for when each backend has one. -
player.urlis only filled by the MPRIS backend (thexesam:urlkey) and by the Android backend (theMEDIA_URImetadata key, which most players do not set); Windows and macOS leave it empty.player.idis the bus name on Linux, the bundle id on macOS, theAppUserModelIdon Windows and the package name on Android — which is also whatplayer.namegets there, since the name an app displays is a resource of that app and cannot be read from outside it. - The JSON output never applies the percentage configuration or the output format — it is always this fixed object.
// Title only, no artist, no progress
{ "type": "media", "format": "{title}" }// A custom line with the player and a bar
{ "type": "media", "format": "{player-name}: {artist} - {combined} [{progress-bar}]", "percent": { "type": 2 } }// Hide the elapsed-time text but keep the percentage
{ "type": "media", "percent": { "type": ["num", "num-color", "hide-others"] } }// Only follow a specific player (top-level setting, not a module option)
{ "general": { "playerName": "spotify" }, "modules": [ { "type": "media" } ] }-
A missing session is not a crash, it is an error that is hidden by default. On Windows a machine
with no media app open reports
winrt: GetCurrentSession() failed; on Linux without DBus,Fastfetch was compiled without DBus support.ffPrintError()obeysdisplay.showErrors, so the text run is blank unless"display": { "showErrors": true }is set. The JSON run always carries theerrorstring. -
{artist}and{title}are not what the default line shows. The default line cleans both (removes(Official Music Video),| Lyrics, …) and drops the artist when it is already part of the title, but the format variables are fed the untouched strings. A format that reproduces the default line has to do that work itself — or use{combined}, which is the cleaned title. -
The player is chosen globally, not per module.
playerNamelives undergeneral, so it affects theplayermodule as well, and it cannot be set from the module's own JSON object. -
Windows needs WinRT and Linux needs DBus at compile time. A Windows build without WinRT reports
Fastfetch is not compiled with WinRT support; a Linux build without DBus reportsFastfetch was compiled without DBus support. Neither falls back to another API. -
macOS 15.4 and newer go through a Perl helper.
MRMediaRemoteGetNowPlayingInfo()stopped working for third-party processes, so the backend spawns/usr/bin/perl— which has to be the Apple-signed one, not the Homebrew build — and the dynamic library calls back into fastfetch throughDynaLoader. If Perl is missing or replaced, playback information is unavailable. -
coveris usuallynullin the JSON. The module asks for the cover withsaveCover = false, so the Windows, macOS and Android backends never write the artwork to a file and the field staysnull. Only the MPRIS backend fills it unconditionally (frommpris:artUrl, as a local path), and the three backends that write a file get a value only when the same run already asked for the cover for a logo (--logo media-cover). The Android one writes a lossless WebP into$TMPDIR, or into/data/local/tmpwhen that variable is unset or not writable, and the file is removed again at exit. -
On Android the display server picks the backend. While SurfaceFlinger is the display server the
media_sessionservice is asked over/dev/binder; a Termux:X11 or Wayland session runs a real compositor on top of the device, and what plays there belongs to that desktop and is not an Android media session at all, so MPRIS answers instead. Only one of the two is consulted, never both. The binder route does still fall back to MPRIS when it comes back empty, which is what an app UID always gets:getSessionsis the one call in the path that checks a permission — the system UI, a holder ofandroid.permission.MEDIA_CONTENT_CONTROLand an enabled notification listener are let through, and no ordinary app is — so an app UID is refused with aSecurityExceptionand, with nothing on the session bus either, the module answersReading the media session needs the shell UID or root. That wording is deliberate: an empty result would read as "nothing is playing". Thedumpsys media_sessionroute has exactly the same restriction. -
A song with no title falls back to the file name. On Linux, when
xesam:titleis empty butxesam:urlis not, the title is derived from the last path segment of the URL, with percent escapes decoded. A session that has neither is treated as "no media" and cleared.
ffDetectMedia() fills a single cached FFMediaResult through the FFcache layer: the first caller of
a run performs the detection, later callers (the module and the player module share it) reuse the
result, and the cache is dropped between --dynamic-interval rounds. When the backend returns no song
and no error, No media found is filled in, and the song / artist / album / player strings are trimmed
of trailing spaces.
FFMediaResult holds error, playerId, player, song, artist, album, url, status,
cover, length and position. Only the MPRIS backend knows a media URL.
ffPrintMedia() builds the default line itself — it cleans song, strips - Topic / VEVO and the
"artist in title" case from the artist, appends the progress and the percentage, and finally the
status. With a custom format the cleaned values are handed to the engine as {combined} (cleaned
song) and the raw values as {title} / {artist}, and the progress, percentage and bar are
pre-rendered into strings so the format engine only has to substitute them.
The session DBus is opened and, without a playerName, the well-known names
org.mpris.MediaPlayer2.spotify, .vlc and .plasma-browser-integration are probed in that order;
if none answers, ListNames is called on the bus and every org.mpris.MediaPlayer2.* name is tried in
turn, skipping playerctld. The whole org.mpris.MediaPlayer2.Player property set is read in one
GetAll call, which yields Metadata (the xesam: and mpris: keys), PlaybackStatus and
Position. The player's friendly name comes from the Identity property, then DesktopEntry, then
the bus name; musikcube is special-cased because its DBus calls are extremely slow.
GlobalSystemMediaTransportControlsSessionManager is activated through WinRT and RequestAsync() is
polled until the async operation completes. Without a playerName, GetCurrentSession() is used;
with one, GetSessions() is enumerated and the first session whose SourceAppUserModelId contains the
name wins (case-insensitively). The title, artist and album come from TryGetMediaPropertiesAsync(),
the status from GetPlaybackInfo() (Closed, Opened, Changing, Stopped, Playing, Paused),
and the timeline from GetTimelineProperties() — the values are in 100-nanosecond units and divided by
10000. While playing, the elapsed time is advanced by the wall-clock time since LastUpdatedTime
multiplied by the playback rate. The friendly player name is resolved from the AppUserModelId
through Windows.ApplicationModel.AppInfo (packaged apps, whose ids contain !) and falling back to
the shell's AppsFolder namespace; if neither resolves, the id is used with a trailing .exe removed.
The cover is read from TryGetMediaPropertiesAsync().Thumbnail, written to a temp file with a fft
prefix.
The MediaRemote framework is loaded weakly — MRMediaRemoteGetNowPlayingInfo(),
MRMediaRemoteGetNowPlayingApplicationIsPlaying(), ...DisplayID() and ...DisplayName() — and the
four callbacks are joined with a dispatch group. kMRMediaRemoteNowPlayingInfoDuration gives the
length and kMRMediaRemoteNowPlayingInfoElapsedTime plus the playback rate and timestamp give the
position. On macOS 15.4 and newer the public API no longer answers for third-party processes, so the
module instead runs /usr/bin/perl with a small script that loads fastfetch's own shared object and
installs ffPrintMediaByMediaRemote as an XSUB; the helper prints one field per line. The cover is
written to NSTemporaryDirectory() as ff_<uuid>.<ext> and removed again at exit.
Which of the two routes answers is decided by the display server, and only one of them is ever
consulted. SurfaceFlinger means the session the phone is playing is Android's own, so the binder route
described below runs; a Termux:X11 or Wayland session puts a compositor on top of the device, and what
plays there is not an Android media session, so media_linux.c — compiled alongside this file for
exactly that reason — answers over the session DBus. Under SurfaceFlinger the binder route still falls
back to it whenever it comes back empty, which is the only route an app UID has.
The media_session service is asked for its sessions with ISessionManager.getSessions(), sent over
/dev/binder through common/android/binder.h — no child process, no JNI. Each session token that
comes back is an ISessionController binder, so the getters are called on it directly:
getPackageName, getMetadata and getPlaybackState.
None of the four transaction codes is written down. They are resolved at run time out of the device's
own framework.jar, by the names of the constants the dex carries for them
(TRANSACTION_getSessions and three more), because a vendor ROM renumbers them as it likes — and
getSessions already differs between releases: it is 2 from Android 12 on but 3 on Android 11, where
notifySession2Created holds the second slot. All four are asked for in one walk, since they sit in
the same class of the same dex entry, and a jar that does not declare one of them is reported rather
than guessed at.
At most four tokens are taken from a reply, so a phone with more sessions than that has the surplus
dropped. The ones that are taken are held only for the duration of the detection:
ffBinderTransact() acquires a strong reference to every handle a reply carries — a handle whose only
reference belonged to the reply buffer degrades to a weak one and fails on first use — and a cleanup
attribute gives them back, together with every descriptor the reply delivered, on every path out.
A phone can have several sessions at once, so every one of them is asked for its playback state first and the best is kept — playing beats buffering beats paused, and a stopped session is only reported when nothing else is there. The metadata and the artwork are then read for that session alone.
getMetadata answers with a Bundle written by Parcel.writeValue, which puts an int32 byte length in
front of every Parcelable value; a walk that does not account for it desynchronises every following
field, and that same length is what makes an unknown Parcelable skippable. Title, artist and album
come from the android.media.metadata.* keys (TITLE, ARTIST, ALBUM, with DISPLAY_TITLE and
DISPLAY_SUBTITLE as the fallbacks meant for streams), and the length from DURATION, which is
already in milliseconds. PlaybackState carries the state, the position, the speed and the update
time; while the session is playing, the position is advanced to now against CLOCK_BOOTTIME — the
clock updateTime is stamped with — so a player that only refreshes its timeline on a seek still
reports a position that moves.
The artwork is a Bitmap and not a path, and its pixels cross the transaction as an ashmem descriptor.
That is why the request is sent with TF_ACCEPT_FDS: without the flag a reply that carries a
descriptor is refused outright, which looks like a service that is not running rather than like a
missing flag, and it only affects players that publish artwork. The pixel count is written in front of
the descriptor, so the region's size and that count have to agree, and the pair of int32s in front of
the count that multiplies out to width * height * 4 are the dimensions. Writing the cover out means
building an image file around those pixels, which is done with AndroidBitmap_compress() — the NDK's
only image encoder, and an API 30 one, so its symbol is weak and the call sits behind a runtime check.
It is asked for lossless WebP: measured on a 363x363 cover that is 146884 bytes in 41 ms, against
203675 bytes in 370 ms for the same encoder's PNG, whose quality argument is ignored outright, and
lossless is what keeps the alpha a Bitmap arrives with. The pixels are mapped read-only instead of
being copied out, and the file goes into $TMPDIR (or /data/local/tmp) and is removed again at exit.
[ { "type": "Media", "result": { "song": { "name": "Get Lucky", "artist": "Daft Punk", "album": "Random Access Memories", "status": "Playing", "length": 367000, "position": 83000, "cover": null }, "player": { "name": "Spotify", "id": "spotify", "url": "" } } } ]