const { GLib } = imports.gi; import App from 'resource:///com/github/Aylur/ags/app.js'; import Widget from 'resource:///com/github/Aylur/ags/widget.js'; import * as Utils from 'resource:///com/github/Aylur/ags/utils.js'; import Mpris from 'resource:///com/github/Aylur/ags/service/mpris.js'; const { exec, execAsync } = Utils; const { Box, EventBox, Icon, Scrollable, Label, Button, Revealer } = Widget; import { fileExists } from '../.miscutils/files.js'; import { AnimatedCircProg } from "../.commonwidgets/cairo_circularprogress.js"; import { showMusicControls } from '../../variables.js'; import { darkMode, hasPlasmaIntegration } from '../.miscutils/system.js'; const COMPILED_STYLE_DIR = `${GLib.get_user_cache_dir()}/ags/user/generated` const LIGHTDARK_FILE_LOCATION = `${GLib.get_user_state_dir()}/ags/user/colormode.txt`; const colorMode = Utils.exec(`bash -c "sed -n \'1p\' '${LIGHTDARK_FILE_LOCATION}'"`); const lightDark = (colorMode == "light") ? '-l' : ''; const COVER_COLORSCHEME_SUFFIX = '_colorscheme.css'; var lastCoverPath = ''; function isRealPlayer(player) { return ( // Remove unecessary native buses from browsers if there's plasma integration !(hasPlasmaIntegration && player.busName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasPlasmaIntegration && player.busName.startsWith('org.mpris.MediaPlayer2.chromium')) && // playerctld just copies other buses and we don't need duplicates !player.busName.startsWith('org.mpris.MediaPlayer2.playerctld') && // Non-instance mpd bus !(player.busName.endsWith('.mpd') && !player.busName.endsWith('MediaPlayer2.mpd')) ); } export const getPlayer = (name = userOptions.music.preferredPlayer) => Mpris.getPlayer(name) || Mpris.players[0] || null; function lengthStr(length) { const min = Math.floor(length / 60); const sec = Math.floor(length % 60); const sec0 = sec < 10 ? '0' : ''; return `${min}:${sec0}${sec}`; } function detectMediaSource(link) { if (link.startsWith("file://")) { if (link.includes('firefox-mpris')) return '󰈹 Firefox' return "󰈣 File"; } let url = link.replace(/(^\w+:|^)\/\//, ''); let domain = url.match(/(?:[a-z]+\.)?([a-z]+\.[a-z]+)/i)[1]; if (domain == 'ytimg.com') return '󰗃 Youtube'; if (domain == 'discordapp.net') return '󰙯 Discord'; if (domain == 'sndcdn.com') return '󰓀 SoundCloud'; return domain; } const DEFAULT_MUSIC_FONT = 'Gabarito, sans-serif'; function getTrackfont(player) { const title = player.trackTitle; const artists = player.trackArtists.join(' '); if (artists.includes('TANO*C') || artists.includes('USAO') || artists.includes('Kobaryo')) return 'Chakra Petch'; // Rigid square replacement if (title.includes('東方')) return 'Crimson Text, serif'; // Serif for Touhou stuff return DEFAULT_MUSIC_FONT; } function trimTrackTitle(title) { if (!title) return ''; const cleanPatterns = [ /【[^】]*】/, // Touhou n weeb stuff " [FREE DOWNLOAD]", // F-777 ]; cleanPatterns.forEach((expr) => title = title.replace(expr, '')); return title; } const TrackProgress = ({ player, ...rest }) => { const _updateProgress = (circprog) => { // const player = Mpris.getPlayer(); if (!player) return; // Set circular progress (see definition of AnimatedCircProg for explanation) circprog.css = `font-size: ${Math.max(player.position / player.length * 100, 0)}px;` } return AnimatedCircProg({ ...rest, className: 'osd-music-circprog', vpack: 'center', extraSetup: (self) => self .hook(Mpris, _updateProgress) .poll(3000, _updateProgress) , }) } const TrackTitle = ({ player, ...rest }) => Label({ ...rest, label: 'No music playing', xalign: 0, truncate: 'end', // wrap: true, className: 'osd-music-title', setup: (self) => self.hook(player, (self) => { // Player name self.label = player.trackTitle.length > 0 ? trimTrackTitle(player.trackTitle) : 'No media'; // Font based on track/artist const fontForThisTrack = getTrackfont(player); self.css = `font-family: ${fontForThisTrack}, ${DEFAULT_MUSIC_FONT};`; }, 'notify::track-title'), }); const TrackArtists = ({ player, ...rest }) => Label({ ...rest, xalign: 0, className: 'osd-music-artists', truncate: 'end', setup: (self) => self.hook(player, (self) => { self.label = player.trackArtists.length > 0 ? player.trackArtists.join(', ') : ''; }, 'notify::track-artists'), }) const CoverArt = ({ player, ...rest }) => { const fallbackCoverArt = Box({ // Fallback className: 'osd-music-cover-fallback', homogeneous: true, children: [Label({ className: 'icon-material txt-gigantic txt-thin', label: 'music_note', })] }); // const coverArtDrawingArea = Widget.DrawingArea({ className: 'osd-music-cover-art' }); // const coverArtDrawingAreaStyleContext = coverArtDrawingArea.get_style_context(); const realCoverArt = Box({ className: 'osd-music-cover-art', homogeneous: true, // children: [coverArtDrawingArea], attribute: { 'pixbuf': null, // 'showImage': (self, imagePath) => { // const borderRadius = coverArtDrawingAreaStyleContext.get_property('border-radius', Gtk.StateFlags.NORMAL); // const frameHeight = coverArtDrawingAreaStyleContext.get_property('min-height', Gtk.StateFlags.NORMAL); // const frameWidth = coverArtDrawingAreaStyleContext.get_property('min-width', Gtk.StateFlags.NORMAL); // let imageHeight = frameHeight; // let imageWidth = frameWidth; // // Get image dimensions // execAsync(['identify', '-format', '{"w":%w,"h":%h}', imagePath]) // .then((output) => { // const imageDimensions = JSON.parse(output); // const imageAspectRatio = imageDimensions.w / imageDimensions.h; // const displayedAspectRatio = imageWidth / imageHeight; // if (imageAspectRatio >= displayedAspectRatio) { // imageWidth = imageHeight * imageAspectRatio; // } else { // imageHeight = imageWidth / imageAspectRatio; // } // // Real stuff // // TODO: fix memory leak(?) // // if (self.attribute.pixbuf) { // // self.attribute.pixbuf.unref(); // // self.attribute.pixbuf = null; // // } // self.attribute.pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imagePath, imageWidth, imageHeight); // coverArtDrawingArea.set_size_request(frameWidth, frameHeight); // coverArtDrawingArea.connect("draw", (widget, cr) => { // // Clip a rounded rectangle area // cr.arc(borderRadius, borderRadius, borderRadius, Math.PI, 1.5 * Math.PI); // cr.arc(frameWidth - borderRadius, borderRadius, borderRadius, 1.5 * Math.PI, 2 * Math.PI); // cr.arc(frameWidth - borderRadius, frameHeight - borderRadius, borderRadius, 0, 0.5 * Math.PI); // cr.arc(borderRadius, frameHeight - borderRadius, borderRadius, 0.5 * Math.PI, Math.PI); // cr.closePath(); // cr.clip(); // // Paint image as bg, centered // Gdk.cairo_set_source_pixbuf(cr, self.attribute.pixbuf, // frameWidth / 2 - imageWidth / 2, // frameHeight / 2 - imageHeight / 2 // ); // cr.paint(); // }); // }).catch(print) // }, 'updateCover': (self) => { // const player = Mpris.getPlayer(); // Maybe no need to re-get player.. can't remember why I had this // Player closed // Note that cover path still remains, so we're checking title if (!player || player.trackTitle == "" || !player.coverPath) { self.css = `background-image: none;`; // CSS image App.applyCss(`${COMPILED_STYLE_DIR}/style.css`); return; } const coverPath = player.coverPath; const stylePath = `${player.coverPath}${darkMode.value ? '' : '-l'}${COVER_COLORSCHEME_SUFFIX}`; if (player.coverPath == lastCoverPath) { // Since 'notify::cover-path' emits on cover download complete Utils.timeout(200, () => { // self.attribute.showImage(self, coverPath); self.css = `background-image: url('${coverPath}');`; // CSS image }); } lastCoverPath = player.coverPath; // If a colorscheme has already been generated, skip generation if (fileExists(stylePath)) { // self.attribute.showImage(self, coverPath) self.css = `background-image: url('${coverPath}');`; // CSS image App.applyCss(stylePath); return; } // Generate colors execAsync(['bash', '-c', `${App.configDir}/scripts/color_generation/generate_colors_material.py --path '${coverPath}' --mode ${darkMode.value ? 'dark' : 'light'} > ${GLib.get_user_state_dir()}/ags/scss/_musicmaterial.scss`]) .then(() => { exec(`wal -i "${player.coverPath}" -n -t -s -e -q ${darkMode.value ? '' : '-l'}`) exec(`cp ${GLib.get_user_cache_dir()}/wal/colors.scss ${GLib.get_user_state_dir()}/ags/scss/_musicwal.scss`); exec(`sass -I "${GLib.get_user_state_dir()}/ags/scss" -I "${App.configDir}/scss/fallback" "${App.configDir}/scss/_music.scss" "${stylePath}"`); Utils.timeout(200, () => { // self.attribute.showImage(self, coverPath) self.css = `background-image: url('${coverPath}');`; // CSS image }); App.applyCss(`${stylePath}`); }) .catch(print); }, }, setup: (self) => self .hook(player, (self) => { self.attribute.updateCover(self); }, 'notify::cover-path') , }); return Box({ ...rest, className: 'osd-music-cover', children: [ Widget.Overlay({ child: fallbackCoverArt, overlays: [realCoverArt], }) ], }) } const TrackControls = ({ player, ...rest }) => Widget.Revealer({ revealChild: false, transition: 'slide_right', transitionDuration: userOptions.animations.durationLarge, child: Widget.Box({ ...rest, vpack: 'center', className: 'osd-music-controls spacing-h-3', children: [ Button({ className: 'osd-music-controlbtn', onClicked: () => player.previous(), child: Label({ className: 'icon-material osd-music-controlbtn-txt', label: 'skip_previous', }) }), Button({ className: 'osd-music-controlbtn', onClicked: () => player.next(), child: Label({ className: 'icon-material osd-music-controlbtn-txt', label: 'skip_next', }) }), ], }), setup: (self) => self.hook(Mpris, (self) => { // const player = Mpris.getPlayer(); if (!player) self.revealChild = false; else self.revealChild = true; }, 'notify::play-back-status'), }); const TrackSource = ({ player, ...rest }) => Widget.Revealer({ revealChild: false, transition: 'slide_left', transitionDuration: userOptions.animations.durationLarge, child: Widget.Box({ ...rest, className: 'osd-music-pill spacing-h-5', homogeneous: true, children: [ Label({ hpack: 'fill', justification: 'center', className: 'icon-nerd', setup: (self) => self.hook(player, (self) => { self.label = detectMediaSource(player.trackCoverUrl); }, 'notify::cover-path'), }), ], }), setup: (self) => self.hook(Mpris, (self) => { const mpris = Mpris.getPlayer(''); if (!mpris) self.revealChild = false; else self.revealChild = true; }), }); const TrackTime = ({ player, ...rest }) => { return Widget.Revealer({ revealChild: false, transition: 'slide_left', transitionDuration: userOptions.animations.durationLarge, child: Widget.Box({ ...rest, vpack: 'center', className: 'osd-music-pill spacing-h-5', children: [ Label({ setup: (self) => self.poll(1000, (self) => { // const player = Mpris.getPlayer(); if (!player) return; self.label = lengthStr(player.position); }), }), Label({ label: '/' }), Label({ setup: (self) => self.hook(Mpris, (self) => { // const player = Mpris.getPlayer(); if (!player) return; self.label = lengthStr(player.length); }), }), ], }), setup: (self) => self.hook(Mpris, (self) => { if (!player) self.revealChild = false; else self.revealChild = true; }), }) } const PlayState = ({ player }) => { var position = 0; const trackCircProg = TrackProgress({ player: player }); return Widget.Button({ className: 'osd-music-playstate', child: Widget.Overlay({ child: trackCircProg, overlays: [ Widget.Button({ className: 'osd-music-playstate-btn', onClicked: () => player.playPause(), child: Widget.Label({ justification: 'center', hpack: 'fill', vpack: 'center', setup: (self) => self.hook(player, (label) => { label.label = `${player.playBackStatus == 'Playing' ? 'pause' : 'play_arrow'}`; }, 'notify::play-back-status'), }), }), ], passThrough: true, }) }); } const MusicControlsWidget = (player) => Box({ className: 'osd-music spacing-h-20 test', children: [ CoverArt({ player: player, vpack: 'center' }), Box({ vertical: true, className: 'spacing-v-5 osd-music-info', children: [ Box({ vertical: true, vpack: 'center', hexpand: true, children: [ TrackTitle({ player: player }), TrackArtists({ player: player }), ] }), Box({ vexpand: true }), Box({ className: 'spacing-h-10', setup: (box) => { box.pack_start(TrackControls({ player: player }), false, false, 0); box.pack_end(PlayState({ player: player }), false, false, 0); if(hasPlasmaIntegration || player.busName.startsWith('org.mpris.MediaPlayer2.chromium')) box.pack_end(TrackTime({ player: player }), false, false, 0) // box.pack_end(TrackSource({ vpack: 'center', player: player }), false, false, 0); } }) ] }) ] }) export default () => Revealer({ transition: 'slide_down', transitionDuration: userOptions.animations.durationLarge, revealChild: false, child: Box({ children: Mpris.bind("players") .as(players => players.map((player) => (isRealPlayer(player) ? MusicControlsWidget(player) : null))) }), setup: (self) => self.hook(showMusicControls, (revealer) => { revealer.revealChild = showMusicControls.value; }), })