✨ feat(center-window/big-media): carousel player controls
This commit is contained in:
@@ -2,239 +2,192 @@ import { timeout } from "ags/time";
|
|||||||
import { Astal, Gtk } from "ags/gtk4";
|
import { Astal, Gtk } from "ags/gtk4";
|
||||||
import { Clipboard } from "../../scripts/clipboard";
|
import { Clipboard } from "../../scripts/clipboard";
|
||||||
import { getMediaUrl, player, setPlayer } from "../bar/Media";
|
import { getMediaUrl, player, setPlayer } from "../bar/Media";
|
||||||
import { Accessor, createBinding, createConnection, For, With } from "ags";
|
import { createBinding, For } from "ags";
|
||||||
import { getPlayerIconFromBusName, pathToURI, variableToBoolean } from "../../scripts/utils";
|
import { pathToURI, variableToBoolean } from "../../scripts/utils";
|
||||||
|
|
||||||
import AstalMpris from "gi://AstalMpris";
|
import AstalMpris from "gi://AstalMpris";
|
||||||
import AstalIO from "gi://AstalIO";
|
import AstalIO from "gi://AstalIO";
|
||||||
import Pango from "gi://Pango?version=1.0";
|
import Pango from "gi://Pango?version=1.0";
|
||||||
|
import Adw from "gi://Adw?version=1";
|
||||||
|
import { register } from "ags/gobject";
|
||||||
|
|
||||||
|
|
||||||
|
let dragTimer: (AstalIO.Time|undefined);
|
||||||
|
|
||||||
export const BigMedia = () => {
|
export const BigMedia = () => {
|
||||||
let dragTimer: (AstalIO.Time|undefined);
|
const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(pls =>
|
||||||
|
pls.filter(p => p.available));
|
||||||
|
|
||||||
|
const carousel = <Adw.Carousel orientation={Gtk.Orientation.HORIZONTAL} spacing={6}
|
||||||
|
onPageChanged={(self, num) => {
|
||||||
|
const page = self.get_nth_page(num);
|
||||||
|
if(page instanceof PlayerWidget && player.get().busName !== page.player.busName)
|
||||||
|
setPlayer(page.player);
|
||||||
|
}}>
|
||||||
|
<For each={availablePlayers.as(players => players.sort(pl =>
|
||||||
|
pl.busName === player.get().busName ? -1 : 1))}>
|
||||||
|
|
||||||
|
{(player: AstalMpris.Player) => <PlayerWidget player={player} />}
|
||||||
|
</For>
|
||||||
|
</Adw.Carousel> as Adw.Carousel;
|
||||||
|
|
||||||
return <Gtk.Box class={"big-media"} orientation={Gtk.Orientation.VERTICAL} widthRequest={255}
|
return <Gtk.Box class={"big-media"} orientation={Gtk.Orientation.VERTICAL} widthRequest={255}
|
||||||
visible={player(pl => pl.available)}>
|
visible={variableToBoolean(availablePlayers)}>
|
||||||
|
|
||||||
<With value={player}>
|
{carousel}
|
||||||
{(player: AstalMpris.Player) => player.available &&
|
<Gtk.Revealer revealChild={availablePlayers.as(pls => pls.length > 1)} transitionDuration={300}
|
||||||
<Gtk.Box orientation={Gtk.Orientation.VERTICAL} hexpand>
|
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}>
|
||||||
|
|
||||||
<Gtk.Revealer hexpand={false} revealChild={
|
<Adw.CarouselIndicatorDots orientation={Gtk.Orientation.HORIZONTAL} carousel={carousel} />
|
||||||
createBinding(player, "artUrl").as(Boolean)
|
</Gtk.Revealer>
|
||||||
} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT} transitionDuration={300}>
|
|
||||||
|
|
||||||
<Gtk.Box class={"image"} css={createBinding(player, "artUrl").as((art) =>
|
|
||||||
`background-image: url("${pathToURI(art)}");`)}
|
|
||||||
hexpand={false} vexpand={false} widthRequest={132} heightRequest={128}
|
|
||||||
valign={Gtk.Align.START} halign={Gtk.Align.CENTER}>
|
|
||||||
|
|
||||||
<PlayerSelectButton player={player} halign={Gtk.Align.END}
|
|
||||||
valign={Gtk.Align.END} />
|
|
||||||
</Gtk.Box>
|
|
||||||
</Gtk.Revealer>
|
|
||||||
|
|
||||||
<Gtk.Box class={"info"} orientation={Gtk.Orientation.VERTICAL}
|
|
||||||
valign={Gtk.Align.CENTER} vexpand hexpand>
|
|
||||||
|
|
||||||
<Gtk.Label class={"title"} tooltipText={
|
|
||||||
createBinding(player, "title").as(title => title ?? "No Title")
|
|
||||||
} label={
|
|
||||||
createBinding(player, "title").as(title => title ?? "No Title")
|
|
||||||
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25}
|
|
||||||
/>
|
|
||||||
<Gtk.Label class={"artist"} tooltipText={
|
|
||||||
createBinding(player, "artist").as(artist => artist ?? "No Artist")
|
|
||||||
} label={
|
|
||||||
createBinding(player, "artist").as(artist => artist ?? "No Artist")
|
|
||||||
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={28}
|
|
||||||
/>
|
|
||||||
</Gtk.Box>
|
|
||||||
|
|
||||||
<Gtk.Box>
|
|
||||||
<With value={createBinding(player, "artUrl").as(Boolean)}>
|
|
||||||
{(hasAlbumArt) => !hasAlbumArt &&
|
|
||||||
<PlayerSelectButton player={player} reveal={true}
|
|
||||||
halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</With>
|
|
||||||
</Gtk.Box>
|
|
||||||
|
|
||||||
<Gtk.Box class={"progress"} hexpand visible={createBinding(player, "canSeek")}>
|
|
||||||
<Astal.Slider hexpand max={createBinding(player, "length").as(Math.floor)}
|
|
||||||
value={createBinding(player, "position").as(Math.floor)}
|
|
||||||
onChangeValue={(_, type, value) => {
|
|
||||||
if(type === undefined || type === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if(!dragTimer) {
|
|
||||||
dragTimer = timeout(200, () =>
|
|
||||||
player.position = Math.floor(value));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragTimer.cancel();
|
|
||||||
dragTimer = timeout(200, () =>
|
|
||||||
player.position = Math.floor(value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Gtk.Box>
|
|
||||||
|
|
||||||
<Gtk.CenterBox class={"bottom"} hexpand marginBottom={6}>
|
|
||||||
<Gtk.Label class={"elapsed"} xalign={0} yalign={0}
|
|
||||||
halign={Gtk.Align.START} label={createBinding(player, "position").as(pos => {
|
|
||||||
const sec = Math.floor(pos % 60);
|
|
||||||
return pos > 0 && player.length > 0 ?
|
|
||||||
`${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}`
|
|
||||||
: "0:00";
|
|
||||||
})} $type="start"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Gtk.Box class={"controls button-row"} $type="center">
|
|
||||||
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
|
|
||||||
tooltipText={"Copy link to clipboard"}
|
|
||||||
visible={variableToBoolean(getMediaUrl(player))}
|
|
||||||
onClicked={() => {
|
|
||||||
const url = getMediaUrl(player).get();
|
|
||||||
url && Clipboard.getDefault().copyAsync(url);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Gtk.Button class={"shuffle"} visible={createBinding(player, "shuffleStatus").as(status =>
|
|
||||||
status !== AstalMpris.Shuffle.UNSUPPORTED)} iconName={
|
|
||||||
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
|
|
||||||
"media-playlist-shuffle-symbolic"
|
|
||||||
: "media-playlist-consecutive-symbolic")} tooltipText={
|
|
||||||
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
|
|
||||||
"Shuffle"
|
|
||||||
: "No shuffle")} onClicked={() => player.shuffle()}
|
|
||||||
/>
|
|
||||||
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
|
|
||||||
tooltipText={"Previous"} onClicked={() => player.canGoPrevious && player.previous()}
|
|
||||||
/>
|
|
||||||
<Gtk.Button class={"play-pause"} tooltipText={
|
|
||||||
createBinding(player, "playbackStatus").as(status =>
|
|
||||||
status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play")}
|
|
||||||
iconName={createBinding(player, "playbackStatus").as(status =>
|
|
||||||
status === AstalMpris.PlaybackStatus.PLAYING ?
|
|
||||||
"media-playback-pause-symbolic"
|
|
||||||
: "media-playback-start-symbolic")} onClicked={() => player.play_pause()}
|
|
||||||
/>
|
|
||||||
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
|
|
||||||
tooltipText={"Next"} onClicked={() => player.canGoNext && player.next()}
|
|
||||||
/>
|
|
||||||
<Gtk.Button class={"repeat"} iconName={createBinding(player, "loopStatus").as(status => {
|
|
||||||
if(status === AstalMpris.Loop.TRACK)
|
|
||||||
return "media-playlist-repeat-song-symbolic";
|
|
||||||
|
|
||||||
if(status === AstalMpris.Loop.PLAYLIST)
|
|
||||||
return "media-playlist-repeat-symbolic";
|
|
||||||
|
|
||||||
return "loop-arrow-symbolic";
|
|
||||||
})} visible={createBinding(player, "loopStatus").as(status =>
|
|
||||||
status !== AstalMpris.Loop.UNSUPPORTED)}
|
|
||||||
tooltipText={createBinding(player, "loopStatus").as(status => {
|
|
||||||
if(status === AstalMpris.Loop.TRACK)
|
|
||||||
return "Loop song";
|
|
||||||
|
|
||||||
if(status === AstalMpris.Loop.PLAYLIST)
|
|
||||||
return "Loop playlist";
|
|
||||||
|
|
||||||
return "No loop";
|
|
||||||
})} onClicked={() => player.loop()}
|
|
||||||
/>
|
|
||||||
</Gtk.Box>
|
|
||||||
<Gtk.Label class={"length"} xalign={1} yalign={0}
|
|
||||||
halign={Gtk.Align.END} label={createBinding(player, "length").as(len => { /* bananananananana */
|
|
||||||
const sec = Math.floor(len % 60);
|
|
||||||
return (len > 0 && Number.isFinite(len)) ?
|
|
||||||
`${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}`
|
|
||||||
: "0:00";
|
|
||||||
})} $type="end"
|
|
||||||
/>
|
|
||||||
</Gtk.CenterBox>
|
|
||||||
</Gtk.Box>
|
|
||||||
}
|
|
||||||
</With>
|
|
||||||
</Gtk.Box> as Gtk.Box;
|
</Gtk.Box> as Gtk.Box;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerSelectButton({ player, reveal, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER }: {
|
@register({ GTypeName: "PlayerWidget" })
|
||||||
player: AstalMpris.Player,
|
class PlayerWidget extends Gtk.Box {
|
||||||
reveal?: Accessor<boolean>|boolean,
|
#player!: AstalMpris.Player;
|
||||||
halign?: Gtk.Align;
|
|
||||||
valign?: Gtk.Align;
|
|
||||||
}) {
|
|
||||||
const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(players =>
|
|
||||||
players.filter(p => p.available));
|
|
||||||
|
|
||||||
return <Gtk.Box vexpand={false} valign={valign} halign={halign}>
|
get player() { return this.#player; }
|
||||||
<With value={availablePlayers.as(apls => apls.length > 1)}>
|
|
||||||
{(show: boolean) => show &&
|
|
||||||
<Gtk.MenuButton halign={Gtk.Align.CENTER} hexpand
|
|
||||||
class={"player-select"} popover={
|
|
||||||
<Gtk.Popover class={"players-list"} hasArrow={false}>
|
|
||||||
<Gtk.Box orientation={Gtk.Orientation.VERTICAL}>
|
|
||||||
<For each={availablePlayers}>
|
|
||||||
{(pl: AstalMpris.Player) =>
|
|
||||||
<Gtk.Button class={"player"} onClicked={() => setPlayer(pl)}>
|
|
||||||
<Gtk.Box>
|
|
||||||
<Gtk.Image iconName={createBinding(pl, "busName").as(
|
|
||||||
getPlayerIconFromBusName
|
|
||||||
)} />
|
|
||||||
<Gtk.Label label={createBinding(pl, "identity")}
|
|
||||||
hexpand={false} class={"identity"} singleLineMode
|
|
||||||
maxWidthChars={8}
|
|
||||||
/>
|
|
||||||
</Gtk.Box>
|
|
||||||
</Gtk.Button>
|
|
||||||
}
|
|
||||||
</For>
|
|
||||||
</Gtk.Box>
|
|
||||||
</Gtk.Popover> as Gtk.Popover
|
|
||||||
} $={(self) => {
|
|
||||||
const controllerMotion = Gtk.EventControllerMotion.new();
|
|
||||||
self.add_controller(controllerMotion);
|
|
||||||
|
|
||||||
self.set_child(
|
constructor({ player }: { player: AstalMpris.Player }) {
|
||||||
<Gtk.Box class={"player"}>
|
super();
|
||||||
<Gtk.Image iconName={createBinding(player, "busName").as(
|
|
||||||
getPlayerIconFromBusName)}
|
|
||||||
/>
|
|
||||||
<Gtk.Revealer transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT}
|
|
||||||
transitionDuration={280} revealChild={reveal ?? createConnection(false,
|
|
||||||
[controllerMotion, "enter", () => {
|
|
||||||
self.add_css_class("reveal");
|
|
||||||
return true;
|
|
||||||
}],
|
|
||||||
[controllerMotion, "leave", () => {
|
|
||||||
self.remove_css_class("reveal");
|
|
||||||
return false;
|
|
||||||
}]
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
|
|
||||||
<Gtk.Box>
|
this.setPlayer(player);
|
||||||
<Gtk.Label label={createBinding(player, "identity")}
|
this.set_orientation(Gtk.Orientation.VERTICAL);
|
||||||
class={"identity"} maxWidthChars={6}
|
this.set_hexpand(true);
|
||||||
ellipsize={Pango.EllipsizeMode.END}
|
|
||||||
tooltipText={createBinding(player, "identity")}
|
this.append(
|
||||||
/>
|
<Gtk.Revealer hexpand={false} revealChild={
|
||||||
<Gtk.Image iconName={
|
createBinding(player, "artUrl").as(Boolean)
|
||||||
createConnection("go-next-symbolic",
|
} transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT} transitionDuration={300}>
|
||||||
[self.popover, "show", () => "go-down-symbolic"],
|
|
||||||
[self.popover, "closed", () => "go-next-symbolic"]
|
<Gtk.Box class={"image"} css={createBinding(player, "artUrl").as((art) =>
|
||||||
)
|
`background-image: url("${pathToURI(art)}");`)}
|
||||||
} class={"arrow"} iconSize={Gtk.IconSize.NORMAL}
|
hexpand={false} vexpand={false} widthRequest={132} heightRequest={128}
|
||||||
/>
|
valign={Gtk.Align.START} halign={Gtk.Align.CENTER}
|
||||||
</Gtk.Box>
|
/>
|
||||||
</Gtk.Revealer>
|
</Gtk.Revealer> as Gtk.Revealer
|
||||||
</Gtk.Box> as Gtk.Box
|
);
|
||||||
);
|
|
||||||
|
this.append(
|
||||||
|
<Gtk.Box class={"info"} orientation={Gtk.Orientation.VERTICAL}
|
||||||
|
valign={Gtk.Align.CENTER} vexpand hexpand>
|
||||||
|
|
||||||
|
<Gtk.Label class={"title"} tooltipText={
|
||||||
|
createBinding(player, "title").as(title => title ?? "No Title")
|
||||||
|
} label={
|
||||||
|
createBinding(player, "title").as(title => title ?? "No Title")
|
||||||
|
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25}
|
||||||
|
/>
|
||||||
|
<Gtk.Label class={"artist"} tooltipText={
|
||||||
|
createBinding(player, "artist").as(artist => artist ?? "No Artist")
|
||||||
|
} label={
|
||||||
|
createBinding(player, "artist").as(artist => artist ?? "No Artist")
|
||||||
|
} ellipsize={Pango.EllipsizeMode.END} maxWidthChars={28}
|
||||||
|
/>
|
||||||
|
</Gtk.Box> as Gtk.Box
|
||||||
|
);
|
||||||
|
|
||||||
|
this.append(
|
||||||
|
<Gtk.Box class={"progress"} hexpand visible={createBinding(player, "canSeek")}>
|
||||||
|
<Astal.Slider hexpand max={createBinding(player, "length").as(Math.floor)}
|
||||||
|
value={createBinding(player, "position").as(Math.floor)}
|
||||||
|
onChangeValue={(_, type, value) => {
|
||||||
|
if(type === undefined || type === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if(!dragTimer) {
|
||||||
|
dragTimer = timeout(200, () =>
|
||||||
|
player.position = Math.floor(value));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragTimer.cancel();
|
||||||
|
dragTimer = timeout(200, () =>
|
||||||
|
player.position = Math.floor(value));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
</Gtk.Box> as Gtk.Box
|
||||||
</With>
|
);
|
||||||
</Gtk.Box>;
|
|
||||||
|
this.append(
|
||||||
|
<Gtk.CenterBox class={"bottom"} hexpand marginBottom={6}>
|
||||||
|
<Gtk.Label class={"elapsed"} xalign={0} yalign={0}
|
||||||
|
halign={Gtk.Align.START} label={createBinding(player, "position").as(pos => {
|
||||||
|
const sec = Math.floor(pos % 60);
|
||||||
|
return pos > 0 && player.length > 0 ?
|
||||||
|
`${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}`
|
||||||
|
: "0:00";
|
||||||
|
})} $type="start"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Gtk.Box class={"controls button-row"} $type="center">
|
||||||
|
<Gtk.Button class={"link"} iconName={"edit-paste-symbolic"}
|
||||||
|
tooltipText={"Copy link to clipboard"}
|
||||||
|
visible={variableToBoolean(getMediaUrl(player))}
|
||||||
|
onClicked={() => {
|
||||||
|
const url = getMediaUrl(player).get();
|
||||||
|
url && Clipboard.getDefault().copyAsync(url);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Gtk.Button class={"shuffle"} visible={createBinding(player, "shuffleStatus").as(status =>
|
||||||
|
status !== AstalMpris.Shuffle.UNSUPPORTED)} iconName={
|
||||||
|
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
|
||||||
|
"media-playlist-shuffle-symbolic"
|
||||||
|
: "media-playlist-consecutive-symbolic")} tooltipText={
|
||||||
|
createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ?
|
||||||
|
"Shuffle"
|
||||||
|
: "No shuffle")} onClicked={() => player.shuffle()}
|
||||||
|
/>
|
||||||
|
<Gtk.Button class={"previous"} iconName={"media-skip-backward-symbolic"}
|
||||||
|
tooltipText={"Previous"} onClicked={() => player.canGoPrevious && player.previous()}
|
||||||
|
/>
|
||||||
|
<Gtk.Button class={"play-pause"} tooltipText={
|
||||||
|
createBinding(player, "playbackStatus").as(status =>
|
||||||
|
status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play")}
|
||||||
|
iconName={createBinding(player, "playbackStatus").as(status =>
|
||||||
|
status === AstalMpris.PlaybackStatus.PLAYING ?
|
||||||
|
"media-playback-pause-symbolic"
|
||||||
|
: "media-playback-start-symbolic")} onClicked={() => player.play_pause()}
|
||||||
|
/>
|
||||||
|
<Gtk.Button class={"next"} iconName={"media-skip-forward-symbolic"}
|
||||||
|
tooltipText={"Next"} onClicked={() => player.canGoNext && player.next()}
|
||||||
|
/>
|
||||||
|
<Gtk.Button class={"repeat"} iconName={createBinding(player, "loopStatus").as(status => {
|
||||||
|
if(status === AstalMpris.Loop.TRACK)
|
||||||
|
return "media-playlist-repeat-song-symbolic";
|
||||||
|
|
||||||
|
if(status === AstalMpris.Loop.PLAYLIST)
|
||||||
|
return "media-playlist-repeat-symbolic";
|
||||||
|
|
||||||
|
return "loop-arrow-symbolic";
|
||||||
|
})} visible={createBinding(player, "loopStatus").as(status =>
|
||||||
|
status !== AstalMpris.Loop.UNSUPPORTED)}
|
||||||
|
tooltipText={createBinding(player, "loopStatus").as(status => {
|
||||||
|
if(status === AstalMpris.Loop.TRACK)
|
||||||
|
return "Loop song";
|
||||||
|
|
||||||
|
if(status === AstalMpris.Loop.PLAYLIST)
|
||||||
|
return "Loop playlist";
|
||||||
|
|
||||||
|
return "No loop";
|
||||||
|
})} onClicked={() => player.loop()}
|
||||||
|
/>
|
||||||
|
</Gtk.Box>
|
||||||
|
<Gtk.Label class={"length"} xalign={1} yalign={0}
|
||||||
|
halign={Gtk.Align.END} label={createBinding(player, "length").as(len => { /* bananananananana */
|
||||||
|
const sec = Math.floor(len % 60);
|
||||||
|
return (len > 0 && Number.isFinite(len)) ?
|
||||||
|
`${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}`
|
||||||
|
: "0:00";
|
||||||
|
})} $type="end"
|
||||||
|
/>
|
||||||
|
</Gtk.CenterBox> as Gtk.CenterBox
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayer(player: AstalMpris.Player) {
|
||||||
|
this.#player = player;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user