mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb022891fe | |||
| 5d9906b8f2 | |||
| 6f7cb468b2 | |||
| 076693e969 | |||
| 781d8055b5 | |||
| 960bb5c660 | |||
| 42bb2bf66f | |||
| f03d88cd8c | |||
| 58f6535ba6 | |||
| 9a59ce3613 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -494,6 +494,9 @@ const createWindow = async (first = true) => {
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
|
||||
app.commandLine.appendSwitch('gtk-version', '3');
|
||||
|
||||
// Must duplicate with the one in renderer process settings.store.ts
|
||||
enum BindingActions {
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
|
||||
@@ -695,58 +695,98 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds
|
||||
? formatCommaDelimitedString(query.albumIds)
|
||||
: undefined;
|
||||
const artistIdsFilter = query.artistIds
|
||||
? formatCommaDelimitedString(query.artistIds)
|
||||
: query.albumArtistIds
|
||||
? formatCommaDelimitedString(query.albumArtistIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
let items: z.infer<typeof jfType._response.song>[] = [];
|
||||
let totalRecordCount = 0;
|
||||
const batchSize = 50;
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
// Handle albumIds fetches in batches to prevent HTTP 414 errors
|
||||
if (query.albumIds && query.albumIds.length > batchSize) {
|
||||
const albumIdBatches = chunk(query.albumIds, batchSize);
|
||||
|
||||
let items: z.infer<typeof jfType._response.song>[];
|
||||
for (const batch of albumIdBatches) {
|
||||
const albumIdsFilter = formatCommaDelimitedString(batch);
|
||||
|
||||
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
||||
// If the Album ID filter is passed, Jellyfin will search for
|
||||
// 1. the matching album id
|
||||
// 2. An album with the name of the album.
|
||||
// It is this second condition causing issues,
|
||||
if (query.albumIds) {
|
||||
const albumIdSet = new Set(query.albumIds);
|
||||
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (items.length < res.body.Items.length) {
|
||||
res.body.TotalRecordCount -= res.body.Items.length - items.length;
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
items = [...items, ...res.body.Items];
|
||||
totalRecordCount += res.body.Items.length;
|
||||
}
|
||||
} else {
|
||||
items = res.body.Items;
|
||||
const albumIdsFilter = query.albumIds
|
||||
? formatCommaDelimitedString(query.albumIds)
|
||||
: undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
GenreIds: query.genreIds?.join(','),
|
||||
IncludeItemTypes: 'Audio',
|
||||
IsFavorite: query.favorite,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
||||
// If the Album ID filter is passed, Jellyfin will search for
|
||||
// 1. the matching album id
|
||||
// 2. An album with the name of the album.
|
||||
// It is this second condition causing issues,
|
||||
if (query.albumIds) {
|
||||
const albumIdSet = new Set(query.albumIds);
|
||||
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
||||
totalRecordCount = items.length;
|
||||
} else {
|
||||
items = res.body.Items;
|
||||
totalRecordCount = res.body.TotalRecordCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -754,7 +794,7 @@ export const JellyfinController: ControllerEndpoint = {
|
||||
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
totalRecordCount,
|
||||
};
|
||||
},
|
||||
getSongListCount: async ({ apiClientProps, query }) =>
|
||||
|
||||
@@ -284,7 +284,7 @@ const normalizeAlbumArtist = (
|
||||
) || [];
|
||||
|
||||
return {
|
||||
albumCount: null,
|
||||
albumCount: item.AlbumCount ?? null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
@@ -308,7 +308,7 @@ const normalizeAlbumArtist = (
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
songCount: item.SongCount ?? null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
|
||||
@@ -431,6 +431,7 @@ const providerIds = z.object({
|
||||
});
|
||||
|
||||
const albumArtist = z.object({
|
||||
AlbumCount: z.number().optional(),
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
DateCreated: z.string(),
|
||||
@@ -446,6 +447,7 @@ const albumArtist = z.object({
|
||||
ProviderIds: providerIds.optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
SongCount: z.number().optional(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
@@ -278,11 +278,25 @@ const normalizeAlbumArtist = (
|
||||
});
|
||||
}
|
||||
|
||||
let albumCount: number;
|
||||
let songCount: number;
|
||||
|
||||
if (item.stats) {
|
||||
albumCount = Math.max(
|
||||
item.stats.albumartist?.albumCount ?? 0,
|
||||
item.stats.artist?.albumCount ?? 0,
|
||||
);
|
||||
songCount = Math.max(
|
||||
item.stats.albumartist?.songCount ?? 0,
|
||||
item.stats.artist?.songCount ?? 0,
|
||||
);
|
||||
} else {
|
||||
albumCount = item.albumCount;
|
||||
songCount = item.songCount;
|
||||
}
|
||||
|
||||
return {
|
||||
albumCount: Math.max(
|
||||
item.stats?.albumartist?.albumCount || item.albumCount,
|
||||
item.stats?.artist?.albumCount || 0,
|
||||
),
|
||||
albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
@@ -307,7 +321,7 @@ const normalizeAlbumArtist = (
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.stats?.albumartist?.songCount || item.songCount,
|
||||
songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
|
||||
return {
|
||||
...ssNormalize.albumArtist(artist, apiClientProps.server, 300),
|
||||
albums: artist.album.map((album) => ssNormalize.album(album, apiClientProps.server)),
|
||||
albums: artist.album?.map((album) => ssNormalize.album(album, apiClientProps.server)),
|
||||
similarArtists:
|
||||
artistInfo?.similarArtist?.map((artist) =>
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server, 300),
|
||||
@@ -305,7 +305,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return artist.body.artist.album;
|
||||
return artist.body.artist.album ?? [];
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -935,7 +935,9 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.albumIds || query.artistIds) {
|
||||
const artistIds = query.albumArtistIds || query.artistIds;
|
||||
|
||||
if (query.albumIds || artistIds) {
|
||||
if (query.albumIds) {
|
||||
for (const albumId of query.albumIds) {
|
||||
fromAlbumPromises.push(
|
||||
@@ -948,8 +950,8 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
}
|
||||
}
|
||||
|
||||
if (query.artistIds) {
|
||||
for (const artistId of query.artistIds) {
|
||||
if (artistIds) {
|
||||
for (const artistId of artistIds) {
|
||||
artistDetailPromises.push(
|
||||
ssApiClient(apiClientProps).getArtist({
|
||||
query: {
|
||||
@@ -966,7 +968,7 @@ export const SubsonicController: ControllerEndpoint = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return artist.body.artist.album;
|
||||
return artist.body.artist.album ?? [];
|
||||
});
|
||||
|
||||
const albumIds = albums.map((album) => album.id);
|
||||
|
||||
@@ -156,7 +156,7 @@ const albumListParameters = z.object({
|
||||
const albumList = z.array(album.omit({ song: true }));
|
||||
|
||||
const albumArtist = z.object({
|
||||
album: z.array(album),
|
||||
album: z.array(album).optional(),
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
|
||||
@@ -388,8 +388,8 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
navigate(
|
||||
generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: e.data.id,
|
||||
generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||
artistId: e.data.id,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -129,7 +129,7 @@ const AlbumListRoute = () => {
|
||||
|
||||
const artist = searchParams.get('artistName');
|
||||
const title = artist
|
||||
? t('page.albumList.artistAlbums', { artist })
|
||||
? t('page.albumList.artistAlbums', { artist, postProcess: 'sentenceCase' })
|
||||
: genreId
|
||||
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
|
||||
: undefined;
|
||||
|
||||
@@ -297,7 +297,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [routeId],
|
||||
type: albumArtistId ? LibraryItem.ALBUM : LibraryItem.ALBUM_ARTIST,
|
||||
type: albumArtistId ? LibraryItem.ALBUM_ARTIST : LibraryItem.ARTIST,
|
||||
},
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
@@ -340,9 +340,15 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
}
|
||||
};
|
||||
|
||||
const albumCount = detailQuery?.data?.albumCount;
|
||||
const artistContextItems =
|
||||
(albumCount ?? 1) > 0
|
||||
? ARTIST_CONTEXT_MENU_ITEMS
|
||||
: ARTIST_CONTEXT_MENU_ITEMS.filter((item) => !item.id.toLowerCase().includes('play'));
|
||||
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
artistContextItems,
|
||||
);
|
||||
|
||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||
@@ -369,7 +375,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Group spacing="md">
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<PlayButton
|
||||
disabled={albumCount === 0}
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
/>
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
|
||||
@@ -28,25 +28,29 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const albumCount = detailQuery?.data?.albumCount;
|
||||
const songCount = detailQuery?.data?.songCount;
|
||||
const duration = detailQuery?.data?.duration;
|
||||
const durationEnabled = duration !== null && duration !== undefined;
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
enabled: detailQuery?.data?.albumCount,
|
||||
enabled: albumCount !== null && albumCount !== undefined,
|
||||
id: 'albumCount',
|
||||
secondary: false,
|
||||
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
|
||||
value: t('entity.albumWithCount', { count: albumCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery?.data?.songCount,
|
||||
enabled: songCount !== null && songCount !== undefined,
|
||||
id: 'songCount',
|
||||
secondary: false,
|
||||
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
|
||||
value: t('entity.trackWithCount', { count: songCount || 0 }),
|
||||
},
|
||||
{
|
||||
enabled: detailQuery.data?.duration,
|
||||
enabled: durationEnabled,
|
||||
id: 'duration',
|
||||
secondary: true,
|
||||
value:
|
||||
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||
value: durationEnabled && formatDurationString(duration),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCallback, useState, Fragment, useRef } from 'react';
|
||||
import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core';
|
||||
import { useDisclosure, useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiSearchLine, RiCloseFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
@@ -37,6 +38,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
const activePage = pages[pages.length - 1];
|
||||
const isHome = activePage === CommandPalettePages.HOME;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const popPage = useCallback(() => {
|
||||
setPages((pages) => {
|
||||
@@ -187,13 +189,17 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
}}
|
||||
>
|
||||
<LibraryCommandItem
|
||||
disabled={artist?.albumCount === 0}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
id={artist.id}
|
||||
imageUrl={artist.imageUrl}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
subtitle={
|
||||
(artist?.albumCount || 0) > 0
|
||||
? `${artist.albumCount} albums`
|
||||
artist?.albumCount !== undefined &&
|
||||
artist?.albumCount !== null
|
||||
? t('entity.albumWithCount', {
|
||||
count: artist.albumCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
title={artist.name}
|
||||
|
||||
@@ -53,6 +53,7 @@ const StyledImage = styled.img`
|
||||
const ActionsContainer = styled(Flex)``;
|
||||
|
||||
interface LibraryCommandItemProps {
|
||||
disabled?: boolean;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
@@ -62,6 +63,7 @@ interface LibraryCommandItemProps {
|
||||
}
|
||||
|
||||
export const LibraryCommandItem = ({
|
||||
disabled,
|
||||
id,
|
||||
imageUrl,
|
||||
subtitle,
|
||||
@@ -154,6 +156,7 @@ export const LibraryCommandItem = ({
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.play', { postProcess: 'sentenceCase' }),
|
||||
@@ -166,6 +169,7 @@ export const LibraryCommandItem = ({
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.addLast', { postProcess: 'sentenceCase' }),
|
||||
@@ -179,6 +183,7 @@ export const LibraryCommandItem = ({
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('player.addNext', { postProcess: 'sentenceCase' }),
|
||||
|
||||
@@ -15,7 +15,7 @@ const MotionButton = styled(UnstyledButton)`
|
||||
fill: var(--btn-filled-fg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not([disabled]) {
|
||||
background: var(--btn-filled-bg);
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -28,6 +28,10 @@ const MotionButton = styled(UnstyledButton)`
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user