Merge branch 'feature/artist-enrichment' into pre-release

This commit is contained in:
Victor RH committed 2026-01-25 21:53:28 -06:00
commit 596f8ab524
10 files changed
+610 -12

No files matched your search

+6 -4
View File
@@ -402,16 +402,18 @@ fn extract_relationships(artist: &ArtistFullResponse) -> ArtistRelationships {
begin: relation.begin.clone(),
end: relation.end.clone(),
}),
ended: relation.ended.unwrap_or(false),
};
match relation.relation_type.as_str() {
"member of band" => {
if relation.direction.as_deref() == Some("backward") {
groups.push(related);
} else if relation.ended == Some(true) {
past_members.push(related);
} else {
// We're viewing a BAND, the related artist is a MEMBER
// All members go to the same list - don't separate by ended status
members.push(related);
} else {
// We're viewing a PERSON, the related artist is a BAND/GROUP
groups.push(related);
}
}
"collaboration" => {
+1
View File
@@ -328,6 +328,7 @@ pub struct RelatedArtist {
pub name: String,
pub role: Option<String>,
pub period: Option<Period>,
pub ended: bool,
}
/// Time period for a relationship
+36
View File
@@ -392,6 +392,33 @@ function categorizeAlbum(album: QobuzAlbum, mainArtistId: number): 'tributes' |
return 'albums';
}
/**
* Extract unique labels from albums
* Only considers albums categorized as 'albums' (main Discography)
* to avoid pollution from tributes, compilations, etc.
*/
function extractLabelsFromAlbums(albums: QobuzAlbum[], artistId: number): { id: number; name: string }[] {
const labelMap = new Map<number, string>();
for (const album of albums) {
// Only extract labels from main discography albums
const category = categorizeAlbum(album, artistId);
if (category !== 'albums') continue;
if (album.label?.id && album.label?.name) {
// Only add if we haven't seen this label ID before
if (!labelMap.has(album.label.id)) {
labelMap.set(album.label.id, album.label.name);
}
}
}
// Convert to array and sort by name
return Array.from(labelMap.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Convert Qobuz API artist response to UI ArtistDetail model
*/
@@ -443,6 +470,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
tributes,
others,
playlists: toArtistPlaylists(artist.playlists),
labels: extractLabelsFromAlbums(albumItems, artist.id),
totalAlbums: artist.albums?.total || artist.albums_count || 0,
albumsFetched
};
@@ -507,6 +535,13 @@ export function appendArtistAlbums(
}
}
// Merge new labels from new albums (only from main discography)
const existingLabelIds = new Set(artist.labels.map(l => l.id));
const newLabels = extractLabelsFromAlbums(newAlbums, artist.id)
.filter(l => !existingLabelIds.has(l.id));
const labels = [...artist.labels, ...newLabels]
.sort((a, b) => a.name.localeCompare(b.name));
return {
...artist,
albums,
@@ -514,6 +549,7 @@ export function appendArtistAlbums(
liveAlbums,
tributes,
others,
labels,
totalAlbums: totalAlbums ?? artist.totalAlbums,
albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length
};
@@ -1,13 +1,14 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info } from 'lucide-svelte';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc } from 'lucide-svelte';
import type { ArtistDetail, QobuzArtist } from '$lib/types';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
import { consumeContextTrackFocus, setPlaybackContext, getPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
import { getQueue, syncQueueState, playQueueIndex } from '$lib/stores/queueStore';
import { tick } from 'svelte';
import { subscribeContentSidebar, toggleContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
import { tick, onMount, onDestroy } from 'svelte';
interface Track {
id: number;
@@ -73,6 +74,7 @@
onTrackShareSonglink?: (track: Track) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onLabelClick?: (labelId: number, labelName?: string) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
}
@@ -104,6 +106,7 @@
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onLabelClick,
activeTrackId = null,
isPlaybackActive = false
}: Props = $props();
@@ -117,6 +120,18 @@
let isRadioLoading = $state(false);
let radioLoadingMessage = $state('');
let radioJustCreated = $state(false);
let showNetworkSidebar = $state(false);
let unsubscribeSidebar: (() => void) | null = null;
onMount(() => {
unsubscribeSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
showNetworkSidebar = active === 'network';
});
});
onDestroy(() => {
unsubscribeSidebar?.();
});
let similarArtists = $state<QobuzArtist[]>([]);
let similarArtistsLoading = $state(false);
let similarArtistImageErrors = $state<Set<number>>(new Set());
@@ -127,6 +142,7 @@
name: string;
role?: string;
period?: { begin?: string; end?: string };
ended: boolean;
}
interface ArtistRelationships {
members: RelatedArtist[];
@@ -134,9 +150,17 @@
groups: RelatedArtist[];
collaborators: RelatedArtist[];
}
interface GroupedMember {
mbid: string;
name: string;
roles: string[];
period?: { begin?: string; end?: string };
ended: boolean;
}
let mbRelationships = $state<ArtistRelationships | null>(null);
let mbRelationshipsLoading = $state(false);
let mbArtistMbid = $state<string | null>(null);
let mbAvailable = $state(true); // Assume available until proven otherwise
let artistDetailEl = $state<HTMLDivElement | null>(null);
let aboutSection = $state<HTMLDivElement | null>(null);
let topTracksSection = $state<HTMLDivElement | null>(null);
@@ -480,11 +504,13 @@
mbRelationshipsLoading = true;
mbRelationships = null;
mbArtistMbid = null;
mbAvailable = true; // Reset on each load attempt
try {
// Check if MusicBrainz is enabled
const enabled = await invoke<boolean>('musicbrainz_is_enabled');
if (!enabled) {
mbAvailable = false;
mbRelationshipsLoading = false;
return;
}
@@ -519,6 +545,7 @@
};
} catch (err) {
console.error('Failed to load MusicBrainz relationships:', err);
mbAvailable = false;
mbRelationships = null;
} finally {
mbRelationshipsLoading = false;
@@ -555,8 +582,44 @@
let hasRelationships = $derived(
mbRelationships &&
(mbRelationships.members.length > 0 ||
mbRelationships.pastMembers.length > 0 ||
mbRelationships.groups.length > 0)
mbRelationships.groups.length > 0 ||
mbRelationships.collaborators.length > 0)
);
// Group members by MBID, combining their roles
function groupMembersByMbid(members: RelatedArtist[]): GroupedMember[] {
const grouped = new Map<string, GroupedMember>();
for (const member of members) {
const existing = grouped.get(member.mbid);
if (existing) {
if (member.role && !existing.roles.includes(member.role)) {
existing.roles.push(member.role);
}
// If any entry is ended, mark as ended
if (member.ended) {
existing.ended = true;
}
} else {
grouped.set(member.mbid, {
mbid: member.mbid,
name: member.name,
roles: member.role ? [member.role] : [],
period: member.period,
ended: member.ended
});
}
}
return Array.from(grouped.values());
}
let groupedMembers = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.members) : []
);
let groupedGroups = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.groups) : []
);
let groupedCollaborators = $derived(
mbRelationships ? groupMembersByMbid(mbRelationships.collaborators) : []
);
function getSimilarArtistImage(similar: QobuzArtist): string {
@@ -1032,6 +1095,7 @@
</div>
{/if}
<!-- TEMPORARILY HIDDEN FOR SIDEBAR EXPERIMENTS
{#if similarArtistsLoading}
<div class="similar-loading">Loading similar artists...</div>
{:else if similarArtists.length > 0}
@@ -1054,7 +1118,7 @@
</div>
{/if}
<!-- MusicBrainz Relationships -->
MusicBrainz Relationships
{#if mbRelationshipsLoading}
<div class="mb-relationships-loading">Loading artist relationships...</div>
{:else if hasRelationships}
@@ -1120,6 +1184,7 @@
{/if}
</div>
{/if}
END TEMPORARILY HIDDEN -->
<!-- Action Buttons -->
<div class="artist-actions">
@@ -1153,6 +1218,14 @@
{/key}
{/if}
</div>
<button
class="network-btn"
class:active={showNetworkSidebar}
onclick={() => toggleContentSidebar('network')}
title="Artist Network"
>
<img src="/element-connect.svg" alt="Network" class="network-icon" />
</button>
</div>
</div>
</div>
@@ -1841,6 +1914,137 @@
{/if}
</div>
{/if}
<!-- Artist Network Sidebar -->
{#if showNetworkSidebar}
<aside class="network-sidebar">
<div class="sidebar-header">
<div class="sidebar-header-icon">
<img src="/element-connect.svg" alt="" />
</div>
<div class="sidebar-header-text">
<h3 class="sidebar-title">Network</h3>
<div class="sidebar-subtitle">{artist.name}</div>
</div>
<button class="sidebar-close" onclick={() => toggleContentSidebar('network')} title="Close">
<X size={18} />
</button>
</div>
<div class="sidebar-content">
<!-- Labels Section -->
<section class="sidebar-section">
<h4 class="section-label">LABELS</h4>
<div class="section-items">
{#if artist.labels.length > 0}
{#each artist.labels as label}
<button
class="sidebar-artist-link"
onclick={() => onLabelClick?.(label.id, label.name)}
title={label.name}
>
<Disc size={12} />
{label.name}
</button>
{/each}
{:else}
<span class="placeholder-text">No label info</span>
{/if}
</div>
</section>
<!-- Similar Artists Section -->
<section class="sidebar-section">
<h4 class="section-label">SIMILAR ARTISTS</h4>
<div class="section-items">
{#if similarArtistsLoading}
<span class="placeholder-text">Loading...</span>
{:else if similarArtists.length > 0}
{#each similarArtists as similar}
<button
class="sidebar-artist-link"
onclick={() => onTrackGoToArtist?.(similar.id)}
title={similar.name}
>
<User size={12} />
{similar.name}
</button>
{/each}
{:else}
<span class="placeholder-text">No similar artists found</span>
{/if}
</div>
</section>
<!-- MusicBrainz Relationships (only shown if MB is enabled and available) -->
{#if mbAvailable}
<section class="sidebar-section">
<h4 class="section-label">RELATIONSHIPS</h4>
<div class="section-items">
{#if mbRelationshipsLoading}
<span class="placeholder-text">Loading...</span>
{:else if hasRelationships}
{#if groupedMembers.length > 0}
<div class="relationship-group">
<span class="relationship-label">Members & Former</span>
{#each groupedMembers as member}
{@const periodStr = member.period?.begin || member.period?.end
? `${member.period.begin || '?'} - ${member.period.end || 'present'}`
: ''}
{@const tooltipParts = [...member.roles]}
{@const tooltip = tooltipParts.length > 0
? (periodStr ? `${tooltipParts.join(', ')} (${periodStr})` : tooltipParts.join(', '))
: (periodStr || member.name)}
<button
class="sidebar-artist-link"
onclick={() => navigateToRelatedArtist(member.name)}
title={tooltip}
>
<User size={12} />
{member.name}
</button>
{/each}
</div>
{/if}
{#if groupedGroups.length > 0}
<div class="relationship-group">
<span class="relationship-label">Member Of</span>
{#each groupedGroups as group}
<button
class="sidebar-artist-link"
onclick={() => navigateToRelatedArtist(group.name)}
title={group.roles.length > 0 ? group.roles.join(', ') : group.name}
>
<Music size={12} />
{group.name}
</button>
{/each}
</div>
{/if}
{#if groupedCollaborators.length > 0}
<div class="relationship-group">
<span class="relationship-label">Collaborators</span>
{#each groupedCollaborators as collab}
<button
class="sidebar-artist-link"
onclick={() => navigateToRelatedArtist(collab.name)}
title={collab.roles.length > 0 ? collab.roles.join(', ') : collab.name}
>
<User size={12} />
{collab.name}
</button>
{/each}
</div>
{/if}
{:else}
<span class="placeholder-text">No relationships found</span>
{/if}
</div>
</section>
{/if}
</div>
</aside>
{/if}
</div>
<style>
@@ -1853,6 +2057,175 @@
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
position: relative;
}
/* Network Sidebar - matches LyricsSidebar dimensions */
.network-sidebar {
position: fixed;
top: 32px;
right: 0;
width: 340px;
height: calc(100vh - 32px - 72px);
background: var(--bg-secondary);
border-left: 1px solid var(--bg-tertiary);
z-index: 100;
display: flex;
flex-direction: column;
animation: slideIn 200ms ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--bg-tertiary);
background: var(--bg-primary);
flex-shrink: 0;
}
.sidebar-header-icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--bg-tertiary);
border-radius: 8px;
color: var(--accent-primary);
}
.sidebar-header-icon img {
width: 18px;
height: 18px;
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
}
.sidebar-header-text {
flex: 1;
min-width: 0;
}
.sidebar-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin: 0;
}
.sidebar-subtitle {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
transition: background 150ms ease, color 150ms ease;
}
.sidebar-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section:last-child {
margin-bottom: 0;
}
.section-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.5px;
margin: 0 0 12px 0;
}
.section-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.placeholder-text {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
}
.sidebar-artist-link {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-align: left;
transition: background 150ms ease, color 150ms ease;
}
.sidebar-artist-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.relationship-group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
}
.relationship-group:last-child {
margin-bottom: 0;
}
.relationship-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 6px;
}
/* Custom scrollbar */
@@ -2056,6 +2429,42 @@
position: relative;
}
.network-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: background 200ms ease, color 200ms ease;
flex-shrink: 0;
}
.network-btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.network-btn.active {
background: var(--bg-hover);
}
.network-btn .network-icon {
width: 24px;
height: 24px;
filter: brightness(0) saturate(100%) invert(70%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
transition: filter 150ms ease;
}
.network-btn:hover .network-icon,
.network-btn.active .network-icon {
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
}
.floating-message {
position: absolute;
left: 50%;
@@ -2108,9 +2517,9 @@
line-height: 1.7;
color: var(--text-secondary);
font-weight: 300;
/* Smart 3-line clamp */
/* Smart 2-line clamp */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+48 -1
View File
@@ -106,6 +106,8 @@
// Lyrics cache state
let isClearingLyrics = $state(false);
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
let isClearingMusicBrainz = $state(false);
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
// Migration state
let showMigrationModal = $state(false);
@@ -464,6 +466,9 @@
// Load lyrics cache stats
loadLyricsCacheStats();
// Load MusicBrainz cache stats
loadMusicBrainzCacheStats();
// Load audio devices first (includes PipeWire sinks), then settings
// Also load backends and ALSA plugins
Promise.all([
@@ -1492,6 +1497,29 @@
}
}
async function handleClearMusicBrainzCache() {
if (isClearingMusicBrainz) return;
isClearingMusicBrainz = true;
try {
await invoke('musicbrainz_clear_cache');
console.log('MusicBrainz cache cleared');
await loadMusicBrainzCacheStats();
} catch (err) {
console.error('Failed to clear MusicBrainz cache:', err);
} finally {
isClearingMusicBrainz = false;
}
}
async function loadMusicBrainzCacheStats() {
try {
musicBrainzCacheStats = await invoke('musicbrainz_get_cache_stats');
} catch (err) {
console.error('Failed to load MusicBrainz cache stats:', err);
musicBrainzCacheStats = null;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -2162,7 +2190,7 @@
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</div>
<div class="setting-row last">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
<small class="setting-note">
@@ -2186,6 +2214,25 @@
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</div>
<div class="setting-row last">
<div class="setting-info">
<span class="setting-label">MusicBrainz Cache</span>
<small class="setting-note">
{#if musicBrainzCacheStats}
{musicBrainzCacheStats.artists} artists, {musicBrainzCacheStats.relations} relations, {musicBrainzCacheStats.recordings} recordings
{:else}
-
{/if}
</small>
</div>
<button
class="clear-btn"
onclick={handleClearMusicBrainzCache}
disabled={isClearingMusicBrainz || !musicBrainzCacheStats || (musicBrainzCacheStats.artists === 0 && musicBrainzCacheStats.relations === 0 && musicBrainzCacheStats.recordings === 0)}
>
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</div>
</section>
<!-- Flatpak Section (only shown when running in Flatpak) -->
+62
View File
@@ -81,3 +81,65 @@ export function collapseSidebar(): void {
export function toggleSidebar(): void {
setExpanded(!isExpanded);
}
// ============================================
// Content Sidebars (Lyrics, Queue, Network)
// Only one can be open at a time
// ============================================
export type ContentSidebarType = 'lyrics' | 'queue' | 'network' | null;
let activeContentSidebar: ContentSidebarType = null;
const contentSidebarListeners = new Set<(type: ContentSidebarType) => void>();
function notifyContentSidebarListeners(): void {
for (const listener of contentSidebarListeners) {
listener(activeContentSidebar);
}
}
/**
* Subscribe to content sidebar changes
*/
export function subscribeContentSidebar(listener: (type: ContentSidebarType) => void): () => void {
contentSidebarListeners.add(listener);
listener(activeContentSidebar);
return () => contentSidebarListeners.delete(listener);
}
/**
* Get active content sidebar
*/
export function getActiveContentSidebar(): ContentSidebarType {
return activeContentSidebar;
}
/**
* Open a content sidebar (closes others)
*/
export function openContentSidebar(type: ContentSidebarType): void {
activeContentSidebar = type;
notifyContentSidebarListeners();
}
/**
* Close a content sidebar
*/
export function closeContentSidebar(type: ContentSidebarType): void {
if (activeContentSidebar === type) {
activeContentSidebar = null;
notifyContentSidebarListeners();
}
}
/**
* Toggle a content sidebar
*/
export function toggleContentSidebar(type: ContentSidebarType): void {
if (activeContentSidebar === type) {
activeContentSidebar = null;
} else {
activeContentSidebar = type;
}
notifyContentSidebarListeners();
}
+4
View File
@@ -238,6 +238,10 @@ export interface ArtistDetail {
trackCount?: number;
owner?: string;
}[];
labels: {
id: number;
name: string;
}[];
totalAlbums: number;
albumsFetched: number;
}
+22
View File
@@ -242,10 +242,14 @@
// MiniPlayer
import { enterMiniplayerMode } from '$lib/services/miniplayerService';
// Sidebar mutual exclusion
import { closeContentSidebar, subscribeContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
// Lyrics state management
import {
subscribe as subscribeLyrics,
toggleSidebar as toggleLyricsSidebar,
hideSidebar as hideLyricsSidebar,
startWatching as startLyricsWatching,
stopWatching as stopLyricsWatching,
startActiveLineUpdates,
@@ -2078,6 +2082,10 @@
// Subscribe to UI state changes
const unsubscribeUI = subscribeUI(() => {
const uiState = getUIState();
// Close network sidebar when queue opens
if (uiState.isQueueOpen && !isQueueOpen) {
closeContentSidebar('network');
}
isQueueOpen = uiState.isQueueOpen;
isFullScreenOpen = uiState.isFullScreenOpen;
isFocusModeOpen = uiState.isFocusModeOpen;
@@ -2179,6 +2187,18 @@
lyricsActiveIndex = state.activeIndex;
lyricsActiveProgress = state.activeProgress;
lyricsSidebarVisible = state.sidebarVisible;
// Close network sidebar when lyrics opens
if (state.sidebarVisible) {
closeContentSidebar('network');
}
});
// Subscribe to content sidebar for mutual exclusion (network closes lyrics/queue)
const unsubscribeContentSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
if (active === 'network') {
hideLyricsSidebar();
closeQueue();
}
});
// Subscribe to cast state changes
@@ -2307,6 +2327,7 @@
unsubscribePlayer();
unsubscribeQueue();
unsubscribeLyrics();
unsubscribeContentSidebar();
unsubscribeCast();
stopLyricsWatching();
stopActiveLineUpdates();
@@ -2575,6 +2596,7 @@
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
onPlaylistClick={selectPlaylist}
onLabelClick={handleLabelClick}
activeTrackId={currentTrack?.id ?? null}
isPlaybackActive={isPlaying}
/>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.5 9a3.49 3.49 0 0 0-3.45 3h-1.1a2.49 2.49 0 0 0-4.396-1.052L8.878 9.731l3.143-4.225a2.458 2.458 0 0 0 2.98-.019L17.339 8H16v1h3V6h-1v1.243l-2.336-2.512A2.473 2.473 0 0 0 16 3.5a2.5 2.5 0 0 0-5 0 2.474 2.474 0 0 0 .343 1.243L7.947 9.308 4.955 7.947a2.404 2.404 0 0 0-.161-1.438l3.704-1.385-.44 1.371.942.333L10 4 7.172 3l-.334.943 1.01.357-3.659 1.368a2.498 2.498 0 1 0-.682 4.117l2.085 2.688-2.053 2.76a2.5 2.5 0 1 0 .87 3.864l3.484 1.587-1.055.373.334.943L10 21l-1-2.828-.943.333.435 1.354-3.608-1.645A2.471 2.471 0 0 0 5 17.5a2.5 2.5 0 0 0-.058-.527l3.053-1.405 3.476 4.48a2.498 2.498 0 1 0 4.113.075L18 17.707V19h1v-3h-3v1h1.293l-2.416 2.416a2.466 2.466 0 0 0-2.667-.047l-3.283-4.23 2.554-1.176A2.494 2.494 0 0 0 15.95 13h1.1a3.493 3.493 0 1 0 3.45-4zm-7-7A1.5 1.5 0 1 1 12 3.5 1.502 1.502 0 0 1 13.5 2zm0 18a1.5 1.5 0 1 1-1.5 1.5 1.502 1.502 0 0 1 1.5-1.5zM1 7.5a1.5 1.5 0 1 1 2.457 1.145l-.144.112A1.496 1.496 0 0 1 1 7.5zm3.32 1.703a2.507 2.507 0 0 0 .264-.326l2.752 1.251-1.124 1.512zM2.5 19A1.5 1.5 0 1 1 4 17.5 1.502 1.502 0 0 1 2.5 19zm2.037-2.941a2.518 2.518 0 0 0-.193-.234l1.885-2.532 1.136 1.464zm3.76-1.731L6.849 12.46l1.42-1.908L11.1 11.84a2.29 2.29 0 0 0-.033 1.213zM13.5 14a1.5 1.5 0 1 1 1.5-1.5 1.502 1.502 0 0 1-1.5 1.5zm7 1a2.5 2.5 0 1 1 2.5-2.5 2.502 2.502 0 0 1-2.5 2.5zm1.5-2.5a1.5 1.5 0 1 1-1.5-1.5 1.502 1.502 0 0 1 1.5 1.5z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB