feat: add Voice Messages In-Background plugin (#1331)
* feat: add Voice Messages In-Background plugin * fix: clean up completed native voice messages * fix up patch --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
20d1f8fea7
commit
1c39a1e7e8
5 changed files with 682 additions and 0 deletions
53
src/equicordplugins/voiceMessagesInBackground/index.tsx
Normal file
53
src/equicordplugins/voiceMessagesInBackground/index.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import { handlePlaybackRateUpdate, stopPlayback, useBackgroundPlayback } from "./playback";
|
||||
import { VoiceMessageIcon, VoiceMessagesInBackgroundPlayer } from "./player";
|
||||
|
||||
interface PlaybackRateUpdate {
|
||||
playbackType: string;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
const WrappedVoiceMessagesInBackgroundPlayer = ErrorBoundary.wrap(VoiceMessagesInBackgroundPlayer, { noop: true });
|
||||
|
||||
export default definePlugin({
|
||||
name: "Voice Messages In-Background",
|
||||
description: "Keeps voice messages playing across chats with a synchronized mini player.",
|
||||
authors: [EquicordDevs.ELJoOker],
|
||||
tags: ["Voice", "Media", "Chat"],
|
||||
dependencies: ["AudioPlayerAPI", "HeaderBarAPI"],
|
||||
|
||||
patches: [{
|
||||
find: "#{intl::PAUSE_VOICE_MESSAGE_A11Y_LABEL}",
|
||||
replacement: {
|
||||
match: /(?<=\i>0\),\[(\i),(\i)\].{0,50}useState\(!1\).{0,10})(\[\i,\i\]=)(\i\.useState\(!1\))(?=.{0,100}\("none"\))/,
|
||||
replace: "$3$self.useBackgroundPlayback($4,$1,arguments[0]?.src,arguments[0]?.playbackCacheKey,$2)"
|
||||
}
|
||||
}],
|
||||
|
||||
headerBarButton: {
|
||||
icon: VoiceMessageIcon,
|
||||
location: "channeltoolbar",
|
||||
priority: 30,
|
||||
render: () => <WrappedVoiceMessagesInBackgroundPlayer />
|
||||
},
|
||||
|
||||
flux: {
|
||||
MEDIA_PLAYBACK_RATE_UPDATE({ playbackType, rate }: PlaybackRateUpdate) {
|
||||
if (playbackType === "voice_message") handlePlaybackRateUpdate(rate);
|
||||
}
|
||||
},
|
||||
|
||||
useBackgroundPlayback,
|
||||
stop: stopPlayback
|
||||
});
|
||||
299
src/equicordplugins/voiceMessagesInBackground/playback.ts
Normal file
299
src/equicordplugins/voiceMessagesInBackground/playback.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { type AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer";
|
||||
import { FluxDispatcher, SelectedChannelStore, useEffect, useRef } from "@webpack/common";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
type NativePlayingState = [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
|
||||
interface ActivePlayback {
|
||||
attached: boolean;
|
||||
cacheKey?: string;
|
||||
channelId?: string;
|
||||
duration: number;
|
||||
paused: boolean;
|
||||
player: AudioPlayerInterface;
|
||||
position: number;
|
||||
setNativePlaying?: Dispatch<SetStateAction<boolean>>;
|
||||
setNativePosition?: Dispatch<SetStateAction<number>>;
|
||||
speed: number;
|
||||
src: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PlaybackSnapshot {
|
||||
channelId?: string;
|
||||
duration: number;
|
||||
paused: boolean;
|
||||
position: number;
|
||||
speed: number;
|
||||
src: string;
|
||||
}
|
||||
|
||||
let active: ActivePlayback | undefined;
|
||||
let lastCacheSync = 0;
|
||||
let voicePlaybackSpeed = 1;
|
||||
|
||||
const NATIVE_END_TOLERANCE = 0.05;
|
||||
|
||||
function playbackPosition(current: ActivePlayback) {
|
||||
if (current.paused) return current.position;
|
||||
|
||||
const position = current.position + (Date.now() - current.updatedAt) / 1000 * current.speed;
|
||||
return current.duration > 0 ? Math.min(position, current.duration) : position;
|
||||
}
|
||||
|
||||
function updatePosition(current: ActivePlayback, position: number) {
|
||||
current.position = Math.max(0, position);
|
||||
current.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
function syncPlaybackCache(current: ActivePlayback, force = false) {
|
||||
if (!current.cacheKey || current.duration <= 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastCacheSync < 1000) return;
|
||||
lastCacheSync = now;
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MEDIA_PLAYBACK_POSITION_UPDATE",
|
||||
cacheKey: current.cacheKey,
|
||||
position: playbackPosition(current),
|
||||
duration: current.duration
|
||||
});
|
||||
}
|
||||
|
||||
export function stopPlayback() {
|
||||
const current = active;
|
||||
if (!current) return;
|
||||
|
||||
active = undefined;
|
||||
current.setNativePlaying?.(false);
|
||||
current.setNativePosition?.(0);
|
||||
current.player.delete();
|
||||
if (current.cacheKey) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MEDIA_PLAYBACK_POSITION_UPDATE",
|
||||
cacheKey: current.cacheKey,
|
||||
position: 0,
|
||||
duration: Math.max(current.duration, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createBackgroundPlayback(src: string, cacheKey: string | undefined, position: number) {
|
||||
stopPlayback();
|
||||
|
||||
const player = createAudioPlayer(src, {
|
||||
persistent: true,
|
||||
preload: true,
|
||||
speed: voicePlaybackSpeed,
|
||||
onEnded: () => {
|
||||
if (active?.player === player) stopPlayback();
|
||||
},
|
||||
onError: () => {
|
||||
if (active?.player === player) stopPlayback();
|
||||
}
|
||||
});
|
||||
const current: ActivePlayback = active = {
|
||||
attached: true,
|
||||
cacheKey,
|
||||
channelId: SelectedChannelStore.getChannelId(),
|
||||
duration: 0,
|
||||
paused: false,
|
||||
player,
|
||||
position,
|
||||
speed: voicePlaybackSpeed,
|
||||
src,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
player.mute();
|
||||
player.seek(position);
|
||||
|
||||
const { duration } = player;
|
||||
if (duration) {
|
||||
void duration.then(value => {
|
||||
if (active !== current || !Number.isFinite(value)) return;
|
||||
|
||||
current.duration = value;
|
||||
if (current.paused && value > 0 && current.position >= value - NATIVE_END_TOLERANCE) stopPlayback();
|
||||
}).catch(() => {
|
||||
if (active === current) stopPlayback();
|
||||
});
|
||||
}
|
||||
|
||||
lastCacheSync = 0;
|
||||
return current;
|
||||
}
|
||||
|
||||
function playFromNative(
|
||||
src: string,
|
||||
cacheKey: string | undefined,
|
||||
position: number,
|
||||
setNativePlaying: Dispatch<SetStateAction<boolean>>,
|
||||
setNativePosition: Dispatch<SetStateAction<number>>
|
||||
) {
|
||||
const current = active?.src === src
|
||||
? active
|
||||
: createBackgroundPlayback(src, cacheKey, position);
|
||||
|
||||
current.attached = true;
|
||||
current.cacheKey = cacheKey;
|
||||
current.paused = false;
|
||||
current.setNativePlaying = setNativePlaying;
|
||||
current.setNativePosition = setNativePosition;
|
||||
updatePosition(current, position);
|
||||
current.player.mute();
|
||||
current.player.pause();
|
||||
syncPlaybackCache(current, true);
|
||||
}
|
||||
|
||||
function pauseFromNative(src: string, position: number) {
|
||||
const current = active;
|
||||
if (!current || current.src !== src || !current.attached) return;
|
||||
|
||||
const latestPosition = Math.max(position, playbackPosition(current));
|
||||
if (current.duration > 0 && latestPosition >= current.duration - NATIVE_END_TOLERANCE) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition(current, latestPosition);
|
||||
current.paused = true;
|
||||
current.player.pause();
|
||||
syncPlaybackCache(current, true);
|
||||
}
|
||||
|
||||
export function useBackgroundPlayback(
|
||||
nativeState: NativePlayingState,
|
||||
nativePosition: number,
|
||||
src: string,
|
||||
cacheKey: string | undefined,
|
||||
setNativePosition: Dispatch<SetStateAction<number>>
|
||||
) {
|
||||
const [nativePlaying, setNativePlaying] = nativeState;
|
||||
const nativePlayingRef = useRef(nativePlaying);
|
||||
const nativePositionRef = useRef(nativePosition);
|
||||
nativePlayingRef.current = nativePlaying;
|
||||
nativePositionRef.current = nativePosition;
|
||||
|
||||
useEffect(() => {
|
||||
const current = active;
|
||||
if (current?.src === src && !current.attached) {
|
||||
const position = playbackPosition(current);
|
||||
const playing = !current.paused;
|
||||
updatePosition(current, position);
|
||||
current.attached = true;
|
||||
current.setNativePlaying = setNativePlaying;
|
||||
current.setNativePosition = setNativePosition;
|
||||
current.player.mute();
|
||||
current.player.pause();
|
||||
current.player.seek(position);
|
||||
nativePlayingRef.current = playing;
|
||||
nativePositionRef.current = position;
|
||||
setNativePosition(position);
|
||||
setNativePlaying(playing);
|
||||
syncPlaybackCache(current, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const current = active;
|
||||
if (!current || current.src !== src || current.setNativePlaying !== setNativePlaying) return;
|
||||
|
||||
updatePosition(current, nativePositionRef.current);
|
||||
current.attached = false;
|
||||
current.paused = !nativePlayingRef.current;
|
||||
current.setNativePlaying = undefined;
|
||||
current.setNativePosition = undefined;
|
||||
current.player.seek(current.position);
|
||||
current.player.unmute();
|
||||
current.paused ? current.player.pause() : current.player.play();
|
||||
syncPlaybackCache(current, true);
|
||||
};
|
||||
}, [setNativePlaying, setNativePosition, src]);
|
||||
|
||||
useEffect(() => {
|
||||
nativePlayingRef.current
|
||||
? playFromNative(src, cacheKey, nativePositionRef.current, setNativePlaying, setNativePosition)
|
||||
: pauseFromNative(src, nativePositionRef.current);
|
||||
}, [cacheKey, nativePlaying, setNativePlaying, setNativePosition, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = active;
|
||||
if (!current || current.src !== src || current.setNativePosition !== setNativePosition) return;
|
||||
|
||||
updatePosition(current, nativePositionRef.current);
|
||||
syncPlaybackCache(current);
|
||||
}, [nativePosition, setNativePosition, src]);
|
||||
|
||||
return nativeState;
|
||||
}
|
||||
|
||||
export function getPlaybackSnapshot(): PlaybackSnapshot | undefined {
|
||||
const current = active;
|
||||
if (!current) return;
|
||||
|
||||
const position = playbackPosition(current);
|
||||
syncPlaybackCache(current);
|
||||
return {
|
||||
channelId: current.channelId,
|
||||
duration: current.duration,
|
||||
paused: current.paused,
|
||||
position,
|
||||
speed: current.speed,
|
||||
src: current.src
|
||||
};
|
||||
}
|
||||
|
||||
export function togglePlayback() {
|
||||
const current = active;
|
||||
if (!current) return;
|
||||
|
||||
if (current.attached && current.setNativePlaying) {
|
||||
current.setNativePlaying(current.paused);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition(current, playbackPosition(current));
|
||||
current.paused = !current.paused;
|
||||
current.paused ? current.player.pause() : current.player.play();
|
||||
syncPlaybackCache(current, true);
|
||||
}
|
||||
|
||||
export function seekPlayback(position: number) {
|
||||
const current = active;
|
||||
if (!current) return;
|
||||
|
||||
const target = Math.max(0, Math.min(position, current.duration || position));
|
||||
updatePosition(current, target);
|
||||
current.player.seek(target);
|
||||
current.setNativePosition?.(target);
|
||||
syncPlaybackCache(current, true);
|
||||
}
|
||||
|
||||
export function setPlaybackSpeed(speed: number) {
|
||||
const current = active;
|
||||
if (!current) return;
|
||||
|
||||
updatePosition(current, playbackPosition(current));
|
||||
current.speed = speed;
|
||||
current.player.speed = speed;
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MEDIA_PLAYBACK_RATE_UPDATE",
|
||||
playbackType: "voice_message",
|
||||
rate: speed
|
||||
});
|
||||
}
|
||||
|
||||
export function handlePlaybackRateUpdate(speed: number) {
|
||||
voicePlaybackSpeed = speed;
|
||||
const current = active;
|
||||
if (!current || current.speed === speed) return;
|
||||
|
||||
updatePosition(current, playbackPosition(current));
|
||||
current.speed = speed;
|
||||
current.player.speed = speed;
|
||||
}
|
||||
146
src/equicordplugins/voiceMessagesInBackground/player.tsx
Normal file
146
src/equicordplugins/voiceMessagesInBackground/player.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { Microphone } from "@components/Icons";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { useFixedTimer } from "@utils/react";
|
||||
import { formatDuration } from "@utils/text";
|
||||
import { ChannelRouter, ChannelStore, SelectedChannelStore, Slider, Tooltip, useStateFromStores } from "@webpack/common";
|
||||
import type { ComponentProps, ComponentType } from "react";
|
||||
|
||||
import { getPlaybackSnapshot, seekPlayback, setPlaybackSpeed, stopPlayback, togglePlayback } from "./playback";
|
||||
|
||||
const cl = classNameFactory("vc-vmib-");
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
type ControlledSliderProps = ComponentProps<typeof Slider> & { value?: number; };
|
||||
|
||||
const ControlledSlider: ComponentType<ControlledSliderProps> = Slider;
|
||||
|
||||
export const VoiceMessageIcon = Microphone;
|
||||
|
||||
function PlayPauseIcon({ paused }: { paused: boolean; }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
|
||||
<path fill="currentColor" d={paused ? "M8 5v14l11-7Z" : "M6 5h4v14H6Zm8 0h4v14h-4Z"} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden="true">
|
||||
<path fill="currentColor" d="m6.7 5.3 5.3 5.29 5.3-5.3 1.4 1.42-5.29 5.3 5.3 5.29-1.42 1.4-5.3-5.29-5.29 5.3-1.4-1.42 5.29-5.3-5.3-5.29Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function time(seconds: number) {
|
||||
return formatDuration(Math.max(0, seconds) * 1000);
|
||||
}
|
||||
|
||||
export function VoiceMessagesInBackgroundPlayer() {
|
||||
useFixedTimer({ interval: 50 });
|
||||
const snapshot = getPlaybackSnapshot();
|
||||
const selectedChannelId = useStateFromStores(
|
||||
[SelectedChannelStore],
|
||||
() => SelectedChannelStore.getChannelId()
|
||||
);
|
||||
const channel = useStateFromStores(
|
||||
[ChannelStore],
|
||||
() => snapshot?.channelId ? ChannelStore.getChannel(snapshot.channelId) : undefined,
|
||||
[snapshot?.channelId]
|
||||
);
|
||||
|
||||
if (!snapshot || snapshot.channelId === selectedChannelId) return null;
|
||||
|
||||
const channelName = channel?.name ?? "Direct message";
|
||||
const duration = Math.max(snapshot.duration, snapshot.position, 1);
|
||||
const position = Math.min(snapshot.position, duration);
|
||||
const nextSpeed = SPEEDS[(SPEEDS.findIndex(speed => speed === snapshot.speed) + 1) % SPEEDS.length];
|
||||
|
||||
return (
|
||||
<div className={cl("player", { playing: !snapshot.paused })} aria-label="Voice Messages In-Background player">
|
||||
<div className={cl("voice-icon")}>
|
||||
<Microphone width={17} height={17} />
|
||||
</div>
|
||||
<Tooltip text={snapshot.paused ? "Resume voice message" : "Pause voice message"}>
|
||||
{tooltipProps => (
|
||||
<Button
|
||||
{...tooltipProps}
|
||||
aria-label={snapshot.paused ? "Resume voice message" : "Pause voice message"}
|
||||
className={cl("control")}
|
||||
size="iconOnly"
|
||||
variant="none"
|
||||
onClick={togglePlayback}
|
||||
>
|
||||
<PlayPauseIcon paused={snapshot.paused} />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Button
|
||||
aria-label={`Return to ${channelName}`}
|
||||
className={cl("origin")}
|
||||
disabled={!snapshot.channelId}
|
||||
size="min"
|
||||
variant="none"
|
||||
onClick={() => snapshot.channelId && ChannelRouter.transitionToChannel(snapshot.channelId)}
|
||||
>
|
||||
<span className={cl("origin-copy")}>
|
||||
<span className={cl("title")}>Voice message</span>
|
||||
<span className={cl("channel")}>{channelName}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<div className={cl("timeline")}>
|
||||
<span className={cl("time")}>{time(position)}</span>
|
||||
<ControlledSlider
|
||||
key={snapshot.src}
|
||||
aria-label="Voice message position"
|
||||
asValueChanges={seekPlayback}
|
||||
className={cl("seek")}
|
||||
handleSize={10}
|
||||
hideBubble
|
||||
initialValue={position}
|
||||
maxValue={duration}
|
||||
minValue={0}
|
||||
mini
|
||||
onValueChange={seekPlayback}
|
||||
onValueRender={time}
|
||||
value={position}
|
||||
/>
|
||||
<span className={cl("time")}>{time(snapshot.duration)}</span>
|
||||
</div>
|
||||
<Tooltip text="Change playback speed">
|
||||
{tooltipProps => (
|
||||
<Button
|
||||
{...tooltipProps}
|
||||
aria-label={`Playback speed ${snapshot.speed} times`}
|
||||
className={cl("speed")}
|
||||
size="min"
|
||||
variant="none"
|
||||
onClick={() => setPlaybackSpeed(nextSpeed)}
|
||||
>
|
||||
{snapshot.speed}×
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip text="Close voice message player">
|
||||
{tooltipProps => (
|
||||
<Button
|
||||
{...tooltipProps}
|
||||
aria-label="Close voice message player"
|
||||
className={cl("control", "close")}
|
||||
size="iconOnly"
|
||||
variant="none"
|
||||
onClick={stopPlayback}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/equicordplugins/voiceMessagesInBackground/style.css
Normal file
180
src/equicordplugins/voiceMessagesInBackground/style.css
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
.vc-vmib-player {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 1 560px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 330px;
|
||||
max-width: min(560px, 48vw);
|
||||
height: 36px;
|
||||
margin: 0 6px;
|
||||
padding: 0 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
background: var(--background-surface-high);
|
||||
box-shadow: var(--shadow-low);
|
||||
color: var(--text-default);
|
||||
animation: vc-vmib-enter 180ms ease-out;
|
||||
}
|
||||
|
||||
.vc-vmib-player::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--brand-500);
|
||||
}
|
||||
|
||||
.vc-vmib-voice-icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: 2px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--brand-500) 18%, transparent);
|
||||
color: var(--brand-500);
|
||||
}
|
||||
|
||||
.vc-vmib-player-playing .vc-vmib-voice-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border: 2px solid color-mix(in srgb, var(--brand-500) 45%, transparent);
|
||||
border-radius: 50%;
|
||||
animation: vc-vmib-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.vc-vmib-control {
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.vc-vmib-origin {
|
||||
flex: 0 1 112px;
|
||||
justify-content: flex-start;
|
||||
min-width: 76px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.vc-vmib-origin-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.vc-vmib-title,
|
||||
.vc-vmib-channel {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vc-vmib-title {
|
||||
color: var(--text-default);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vc-vmib-channel {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vc-vmib-timeline {
|
||||
display: flex;
|
||||
flex: 1 1 220px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.vc-vmib-seek {
|
||||
flex: 1 1 auto;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.vc-vmib-time {
|
||||
min-width: 34px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-code);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vc-vmib-speed {
|
||||
flex: 0 0 auto;
|
||||
min-width: 34px;
|
||||
color: var(--brand-500);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vc-vmib-close {
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
||||
@keyframes vc-vmib-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vc-vmib-pulse {
|
||||
from {
|
||||
opacity: .65;
|
||||
transform: scale(.85);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1050px) {
|
||||
.vc-vmib-player {
|
||||
max-width: 410px;
|
||||
}
|
||||
|
||||
.vc-vmib-origin {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.vc-vmib-player {
|
||||
min-width: 235px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.vc-vmib-time:last-child,
|
||||
.vc-vmib-voice-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vc-vmib-player,
|
||||
.vc-vmib-player-playing .vc-vmib-voice-icon::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1426,6 +1426,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "k304",
|
||||
id: 255004979637649408n
|
||||
},
|
||||
ELJoOker: {
|
||||
name: "ELJoOker",
|
||||
id: 605894319408283678n
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue