nixos/modules/home/ags/app/widget/Media.tsx

190 lines
6.6 KiB
TypeScript

import Gtk from "gi://Gtk?version=3.0"
import Pango from "gi://Pango"
import AstalMpris from "gi://AstalMpris"
import { Variable, bind } from "astal"
const mpris = AstalMpris.get_default()
const PREV = "media-skip-backward-symbolic"
const PLAY = "media-playback-start-symbolic"
const PAUSE = "media-playback-pause-symbolic"
const NEXT = "media-skip-forward-symbolic"
const NO_ART = "audio-x-generic-symbolic"
function fmtTime(seconds: number) {
if (!seconds || seconds < 0) return "0:00"
const min = Math.floor(seconds / 60)
const sec = Math.floor(seconds % 60)
return `${min}:${sec < 10 ? "0" : ""}${sec}`
}
function playerIcon(entry: string) {
const name = entry ? `${entry.toLowerCase()}-symbolic` : ""
const theme = Gtk.IconTheme.get_default()!
return name !== "" && theme.has_icon(name) ? name : NO_ART
}
function PlayerCard(player: AstalMpris.Player) {
// position only changes on the bus when seeking; poll for smooth progress
const position = Variable(-1).poll(1000, () =>
player.available ? player.position : -1,
)
return (
<box className="player" spacing={8}>
<box
className="cover"
css={bind(player, "cover_art").as((c) =>
c !== "" ? `background-image: url('${encodeURI(`file://${c}`)}')` : "",
)}
>
<icon
pixelSize={24}
icon={NO_ART}
visible={bind(player, "cover_art").as((c) => c === "")}
/>
</box>
<box vertical spacing={4}>
<box spacing={6}>
<label
className="player-title"
hexpand
halign={Gtk.Align.START}
label={bind(player, "title").as((t) => t || "")}
maxWidthChars={20}
ellipsize={Pango.EllipsizeMode.END}
/>
<icon
className="player-icon"
icon={bind(player, "entry").as(playerIcon)}
tooltipText={bind(player, "identity")}
/>
</box>
<label
className="player-artist"
halign={Gtk.Align.START}
label={bind(player, "artist").as((a) => a || "")}
maxWidthChars={24}
ellipsize={Pango.EllipsizeMode.END}
visible={bind(player, "artist").as((a) => (a || "") !== "")}
/>
<slider
className="position"
value={position().as((p) =>
player.length > 0
? Math.min(1, Math.max(0, p / player.length))
: 0,
)}
onDragged={(self) => {
if (player.length > 0) player.position = self.value * player.length
}}
visible={bind(player, "length").as((l) => l > 0)}
/>
<box className="player-footer" visible={bind(player, "length").as((l) => l > 0)}>
<label
className="player-position"
label={position().as((p) => fmtTime(p))}
/>
<box hexpand />
<button
className="player-ctl"
onClicked={() => player.previous()}
visible={bind(player, "can_go_previous")}
>
<icon icon={PREV} />
</button>
<button
className="player-ctl"
onClicked={() => player.play_pause()}
visible={bind(player, "can_play")}
>
<icon
icon={bind(player, "playback_status").as((s) =>
s === AstalMpris.PlaybackStatus.PLAYING ? PAUSE : PLAY,
)}
/>
</button>
<button
className="player-ctl"
onClicked={() => player.next()}
visible={bind(player, "can_go_next")}
>
<icon icon={NEXT} />
</button>
<box hexpand />
<label
className="player-length"
label={bind(player, "length").as((l) => fmtTime(l))}
/>
</box>
</box>
</box>
)
}
export function MediaSection() {
const players = bind(mpris, "players")
return (
<box className="media-section" vertical visible={players.as((ps) => ps.length > 0)}>
{players.as((ps) => ps.map((p) => PlayerCard(p)))}
</box>
)
}
// the players property only notifies on connect/disconnect, so the bar
// button tracks the first player directly to follow track changes
let player: AstalMpris.Player | null = null
let hookIds: Array<number> = []
const hasPlayer = Variable(false)
const title = Variable("")
const icon = Variable(NO_ART)
function sync() {
const current = mpris.players[0] ?? null
if (current !== player) {
hookIds.forEach((id) => player?.disconnect(id))
hookIds = []
player = current
if (player) {
hookIds = [
player.connect("notify::title", sync),
player.connect("notify::entry", sync),
]
}
}
hasPlayer.set(player !== null)
title.set(player?.title || "")
icon.set(player ? playerIcon(player.entry) : "")
}
mpris.connect("notify::players", sync)
sync()
export function MediaButton() {
return (
<button
className="media center"
visible={bind(hasPlayer)}
onClicked={() => {
if (player && player.can_play) player.play_pause()
}}
onScroll={(_, e) => {
if (!player) return
if (e.delta_y < 0) player.next()
else player.previous()
}}
>
<box spacing={6}>
<icon className="media-icon" icon={bind(icon)} />
<label
className="media-title"
label={bind(title)}
maxWidthChars={30}
ellipsize={Pango.EllipsizeMode.END}
/>
</box>
</button>
)
}