mirror of
https://github.com/codeforreal1/compressO
synced 2026-04-21 15:47:56 +00:00
feat: add compare output slider for image and videos
This commit is contained in:
parent
3b03f2c136
commit
f68a1834c7
16 changed files with 543 additions and 64 deletions
|
|
@ -47,6 +47,7 @@
|
|||
"react": "^18",
|
||||
"react-advanced-cropper": "^0.20.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-compare-slider": "^3.1.0",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
|
|
@ -82,8 +83,6 @@
|
|||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,ts,jsx,tsx}": [
|
||||
"biome check --write"
|
||||
]
|
||||
"src/**/*.{js,ts,jsx,tsx}": ["biome check --write"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@cut.media/react-gif-player':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@heroui/react':
|
||||
specifier: 2.7.11
|
||||
version: 2.7.11(@types/react@18.2.37)(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.3)
|
||||
|
|
@ -17,6 +20,9 @@ importers:
|
|||
'@internationalized/date':
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
'@remotion/gif':
|
||||
specifier: ^4.0.437
|
||||
version: 4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.97.21
|
||||
version: 1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -68,6 +74,9 @@ importers:
|
|||
react-colorful:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-compare-slider:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-dom:
|
||||
specifier: ^18
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
|
@ -393,6 +402,15 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cut.media/gif-player@1.1.3':
|
||||
resolution: {integrity: sha512-3vje409ZVspBA/d4Wt+V+qIiKCzrFL5t7wcNXdgtFk1h7tG3hoFdVkx3dsQf1Jsu+BMY6yAE3SWZHN3T+tBu5g==}
|
||||
|
||||
'@cut.media/react-gif-player@1.0.2':
|
||||
resolution: {integrity: sha512-VFb7H+/GUo6cL5Nmu8pYQUP8cYThAa49Z85MCLlIB5psV0GLArnlGX7zV7TqQdgXMdEszRznkj1wvMzDwWqQBA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
'@emotion/is-prop-valid@0.8.8':
|
||||
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
|
||||
|
||||
|
|
@ -1998,6 +2016,12 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
|
||||
'@remotion/gif@4.0.437':
|
||||
resolution: {integrity: sha512-t1k8ww1gboLWDRbtWdwWe9n+LsCqsb0W+B1/6OAJt3mjoAhUDx5ociZLYynaclVDbbDPSGKrBAmsULflt9QeZg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@rollup/pluginutils@5.1.4':
|
||||
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -3442,6 +3466,13 @@ packages:
|
|||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-compare-slider@3.1.0:
|
||||
resolution: {integrity: sha512-TQVbZYmYyTIeKRmQciVXCmUwHjTThQTON7GfWfzMAOInRRG9tCiQnVXnCUd5DJ5l3Hngh4IEzOb9TG82gjoEhQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
|
|
@ -3545,6 +3576,12 @@ packages:
|
|||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
remotion@4.0.437:
|
||||
resolution: {integrity: sha512-mQHiYZwt3HoMngJeTGyytVFdobf/mgsPTiQSUfPP43kA7bEpn4OdaF4hWoMfJhjfTC1ZdkTIRX/s3OKto0aWzg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -4218,6 +4255,15 @@ snapshots:
|
|||
'@biomejs/cli-win32-x64@2.3.2':
|
||||
optional: true
|
||||
|
||||
'@cut.media/gif-player@1.1.3': {}
|
||||
|
||||
'@cut.media/react-gif-player@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@cut.media/gif-player': 1.1.3
|
||||
clsx: 2.1.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@emotion/is-prop-valid@0.8.8':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.7.4
|
||||
|
|
@ -6539,6 +6585,12 @@ snapshots:
|
|||
'@react-types/shared': 3.32.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@remotion/gif@4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
remotion: 4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@rollup/pluginutils@5.1.4(rollup@4.32.0)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
|
|
@ -8123,6 +8175,11 @@ snapshots:
|
|||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-compare-slider@3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
|
@ -8265,6 +8322,11 @@ snapshots:
|
|||
mdast-util-to-markdown: 2.1.2
|
||||
unified: 11.0.5
|
||||
|
||||
remotion@4.0.437(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
|
|||
16
src/assets/icons/compare.svg
Normal file
16
src/assets/icons/compare.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<g clip-path="url(#clip0_105_1836)">
|
||||
<path
|
||||
d="M13 3.99976H6C4.89543 3.99976 4 4.89519 4 5.99976V17.9998C4 19.1043 4.89543 19.9998 6 19.9998H13M17 3.99976H18C19.1046 3.99976 20 4.89519 20 5.99976V6.99976M20 16.9998V17.9998C20 19.1043 19.1046 19.9998 18 19.9998H17M20 10.9998V12.9998M12 1.99976V21.9998"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_105_1836">
|
||||
<rect fill="currentColor" height="24" transform="translate(0 -0.000244141)" width="24"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 929 B |
14
src/components/CompareSlider/index.tsx
Normal file
14
src/components/CompareSlider/index.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import {
|
||||
ReactCompareSlider,
|
||||
type ReactCompareSliderProps,
|
||||
} from 'react-compare-slider'
|
||||
|
||||
type CompareSliderProps = {
|
||||
style?: React.CSSProperties
|
||||
} & ReactCompareSliderProps
|
||||
|
||||
function CompareSlider({ ...props }: CompareSliderProps) {
|
||||
return <ReactCompareSlider {...props} />
|
||||
}
|
||||
|
||||
export default CompareSlider
|
||||
|
|
@ -6,6 +6,7 @@ import Audio from '@/assets/icons/audio.svg?react'
|
|||
import Back from '@/assets/icons/back.svg?react'
|
||||
import Caret from '@/assets/icons/caret.svg?react'
|
||||
import Chevron from '@/assets/icons/chevron.svg?react'
|
||||
import Compare from '@/assets/icons/compare.svg?react'
|
||||
import Copy from '@/assets/icons/copy.svg?react'
|
||||
import Cross from '@/assets/icons/cross.svg?react'
|
||||
import CurvedArrow from '@/assets/icons/curved-arrow.svg?react'
|
||||
|
|
@ -84,6 +85,7 @@ const registry = asRegistry({
|
|||
chevron: Chevron,
|
||||
addMedia: AddMedia,
|
||||
caret: Caret,
|
||||
compare: Compare,
|
||||
})
|
||||
|
||||
export default registry
|
||||
|
|
|
|||
|
|
@ -1,28 +1,99 @@
|
|||
import React from 'react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
import { ThemeProxy, useTheme } from '@/hooks/useTheme'
|
||||
import Button from '../Button'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import { cn } from '@/utils/tailwind'
|
||||
import Icon from '../Icon'
|
||||
import Tooltip from '../Tooltip'
|
||||
|
||||
type ThemeSwitcherChildrenProps = ThemeProxy
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
children?(props: ThemeSwitcherChildrenProps): React.ReactNode
|
||||
interface ThemeSwitcherProps extends React.ComponentPropsWithoutRef<'button'> {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
function ThemeSwitcher(props: ThemeSwitcherProps) {
|
||||
const { children } = props
|
||||
|
||||
export const ThemeSwitcher = ({
|
||||
className,
|
||||
duration = 400,
|
||||
}: ThemeSwitcherProps) => {
|
||||
const { theme, setTheme, toggleTheme } = useTheme()
|
||||
|
||||
return children == null ? (
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
setTheme(
|
||||
document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
)
|
||||
}
|
||||
|
||||
updateTheme()
|
||||
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [setTheme])
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
|
||||
const { top, left, width, height } = button.getBoundingClientRect()
|
||||
const x = left + width / 2
|
||||
const y = top + height / 2
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(x, viewportWidth - x),
|
||||
Math.max(y, viewportHeight - y),
|
||||
)
|
||||
|
||||
const applyTheme = () => {
|
||||
toggleTheme()
|
||||
document.documentElement.classList.toggle('dark')
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition !== 'function') {
|
||||
applyTheme()
|
||||
return
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme)
|
||||
})
|
||||
|
||||
const ready = transition?.ready
|
||||
if (ready && typeof ready.then === 'function') {
|
||||
ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${maxRadius}px at ${x}px ${y}px)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
duration,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [toggleTheme, duration])
|
||||
|
||||
return (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
toggleTheme()
|
||||
}}
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
radius="md"
|
||||
className={cn(className)}
|
||||
onPress={handleThemeToggle}
|
||||
>
|
||||
<Tooltip
|
||||
content="Toggle theme"
|
||||
|
|
@ -31,10 +102,7 @@ function ThemeSwitcher(props: ThemeSwitcherProps) {
|
|||
>
|
||||
<Icon name={theme === 'light' ? 'moon' : 'sun'} />
|
||||
</Tooltip>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
) : (
|
||||
children({ theme, setTheme, toggleTheme })
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSwitcher
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ export interface VideoPlayerProps extends BaseReactPlayerProps {
|
|||
contextMenu?: React.ReactNode
|
||||
onSpaceKeydownForPlayPause?: () => void
|
||||
onArrowKeySeek?: (arrowKey: 'left' | 'right') => void
|
||||
disablePlayPauseControlAtCenter?: boolean
|
||||
autoPlay?: boolean
|
||||
disablePlayPauseViaContainerClick?: boolean
|
||||
}
|
||||
|
||||
const scales: TimelineScales = {
|
||||
|
|
@ -63,6 +66,9 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
disableClosedCaptions,
|
||||
contextMenu,
|
||||
onSpaceKeydownForPlayPause,
|
||||
disablePlayPauseControlAtCenter,
|
||||
autoPlay,
|
||||
disablePlayPauseViaContainerClick,
|
||||
onArrowKeySeek,
|
||||
onProgress,
|
||||
onDuration,
|
||||
|
|
@ -70,6 +76,7 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
onPause,
|
||||
onEnded,
|
||||
onReady,
|
||||
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
|
|
@ -319,7 +326,7 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
if (playPauseButtonRef.current) {
|
||||
playPauseButtonRef.current.style.visibility = 'hidden'
|
||||
}
|
||||
}, 3000)
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(playPauseButtonRefIntervalId.current!)
|
||||
if (playPauseButtonRef.current) {
|
||||
|
|
@ -357,8 +364,12 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
<div className={cn('w-full h-full', containerClassName)}>
|
||||
<div
|
||||
className="relative w-full h-full"
|
||||
role="button"
|
||||
onClick={togglePlayPause}
|
||||
{...(!disablePlayPauseViaContainerClick
|
||||
? {
|
||||
role: 'button',
|
||||
onClick: togglePlayPause,
|
||||
}
|
||||
: {})}
|
||||
onContextMenu={(e) => {
|
||||
if (contextMenu) {
|
||||
handleContextMenu(e)
|
||||
|
|
@ -403,22 +414,27 @@ const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
|
|||
onReady={(player) => {
|
||||
onReady?.(player)
|
||||
toggleClosedCaptions(disableClosedCaptions)
|
||||
if (autoPlay) {
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
ref={playPauseButtonRef}
|
||||
onPress={togglePlayPause}
|
||||
isIconOnly
|
||||
radius="full"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/30 cursor-pointer"
|
||||
>
|
||||
<Icon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
size={28}
|
||||
className="text-white drop-shadow-lg"
|
||||
/>
|
||||
</Button>
|
||||
{!disablePlayPauseControlAtCenter ? (
|
||||
<Button
|
||||
ref={playPauseButtonRef}
|
||||
onPress={togglePlayPause}
|
||||
isIconOnly
|
||||
radius="full"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/30 cursor-pointer"
|
||||
>
|
||||
<Icon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
size={28}
|
||||
className="text-white drop-shadow-lg"
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{contextMenuOpen && contextMenuPosition && contextMenu ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -155,6 +155,29 @@ body > * {
|
|||
cursor: text !important;
|
||||
}
|
||||
|
||||
/* Tailwind CSS Animations */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--animate-ripple: ripple var(--duration, 2s) ease calc(var(--i, 0) * 0.2s)
|
||||
infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
.timeline-editor .ReactVirtualized__Grid,
|
||||
.timeline-editor .ReactVirtualized__Grid::before,
|
||||
.timeline-editor .ReactVirtualized__Grid::after {
|
||||
|
|
@ -216,3 +239,8 @@ body > * {
|
|||
.text-shadow {
|
||||
text-shadow: 4px 4px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.__rcs-handle-button {
|
||||
width: 45px !important;
|
||||
height: 45px !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function MediaConfig() {
|
|||
'relative w-full h-full px-4 py-6 rounded-xl border-2 border-zinc-200 dark:border-zinc-800',
|
||||
)}
|
||||
>
|
||||
{selectedMediaIndexForCustomization === -1 ? (
|
||||
{media.length > 1 && selectedMediaIndexForCustomization === -1 ? (
|
||||
<motion.div
|
||||
className="flex justify-center absolute left-1/2 top-[-16px]"
|
||||
{...zoomInTransition}
|
||||
|
|
|
|||
164
src/routes/(root)/ui/MediaOutputCompareSlider.tsx
Normal file
164
src/routes/(root)/ui/MediaOutputCompareSlider.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import merge from 'lodash/merge'
|
||||
import { memo, useCallback, useId } from 'react'
|
||||
import { ReactCompareSliderHandle } from 'react-compare-slider'
|
||||
import { PhotoView } from 'react-photo-view'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import CompareSlider from '@/components/CompareSlider'
|
||||
import Icon from '@/components/Icon'
|
||||
import ImageViewer from '@/components/ImageViewer'
|
||||
import Tooltip from '@/components/Tooltip'
|
||||
import VideoPlayer from '@/components/VideoPlayer'
|
||||
import { cn } from '@/utils/tailwind'
|
||||
import { appProxy } from '../-state'
|
||||
|
||||
type MediaOutputCompareSliderProps = {
|
||||
mediaIndex: number
|
||||
}
|
||||
|
||||
type renderImageDefaultType = {
|
||||
isOriginal?: boolean
|
||||
enableZoomViewer?: boolean
|
||||
}
|
||||
|
||||
const renderImageDefaultOptions: renderImageDefaultType = {
|
||||
isOriginal: true,
|
||||
enableZoomViewer: true,
|
||||
}
|
||||
|
||||
type renderVideoDefaultType = {
|
||||
isOriginal?: boolean
|
||||
}
|
||||
|
||||
const renderVideoDefaultOptions: renderImageDefaultType = {
|
||||
isOriginal: true,
|
||||
}
|
||||
|
||||
function MediaOutputCompareSlider({
|
||||
mediaIndex,
|
||||
}: MediaOutputCompareSliderProps) {
|
||||
if (mediaIndex < 0) return
|
||||
|
||||
const {
|
||||
state: { media },
|
||||
} = useSnapshot(appProxy)
|
||||
const mediaFile = media.length === 1 ? media[0] : null
|
||||
|
||||
const id = useId()
|
||||
|
||||
const renderImage = useCallback(
|
||||
(src: string, options?: renderImageDefaultType) => {
|
||||
options = merge({}, renderImageDefaultOptions, options ?? {})
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
id={`image-comparison-${options?.isOriginal ? 0 : 1}`}
|
||||
key={`image-comparison-${options?.isOriginal ? 0 : 1}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`compare image ${options?.isOriginal ? 0 : 1}`}
|
||||
className="max-h-[65vh] object-contain"
|
||||
{...(mediaFile?.dimensions?.width
|
||||
? { width: mediaFile?.dimensions?.width }
|
||||
: {})}
|
||||
/>
|
||||
{options?.enableZoomViewer ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-4',
|
||||
options?.isOriginal
|
||||
? 'left-4 isolate z-[100]'
|
||||
: 'right-4 z-[100]',
|
||||
)}
|
||||
>
|
||||
<ImageViewer>
|
||||
<PhotoView src={src!}>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={`Enlarge ${options?.isOriginal ? 'input' : 'output'}`}
|
||||
>
|
||||
<Icon name="zoom" size={18} className="cursor-pointer" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PhotoView>
|
||||
</ImageViewer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[mediaFile?.dimensions?.width],
|
||||
)
|
||||
|
||||
const renderVideo = useCallback(
|
||||
(src: string, options?: renderVideoDefaultType) => {
|
||||
options = merge({}, renderVideoDefaultOptions, options ?? {})
|
||||
|
||||
return (
|
||||
<VideoPlayer
|
||||
id={`video-comparison-${options?.isOriginal ? 0 : 1}`}
|
||||
key={`video-comparison-${options?.isOriginal ? 0 : 1}`}
|
||||
url={src}
|
||||
autoPlay
|
||||
loop
|
||||
controls={false}
|
||||
playPauseOnSpaceKeydown={false}
|
||||
disableClosedCaptions
|
||||
disablePlayPauseControlAtCenter
|
||||
disablePlayPauseViaContainerClick
|
||||
containerClassName="w-full h-full"
|
||||
{...(mediaFile?.dimensions?.width
|
||||
? { width: mediaFile?.dimensions?.width }
|
||||
: {})}
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: '50vw',
|
||||
maxHeight: '65vh',
|
||||
aspectRatio:
|
||||
(mediaFile?.dimensions?.width ?? 1) /
|
||||
(mediaFile?.dimensions?.height ?? 1),
|
||||
}}
|
||||
muted={options?.isOriginal}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[mediaFile?.dimensions?.width, mediaFile?.dimensions?.height],
|
||||
)
|
||||
|
||||
return (
|
||||
<div id={id} className="rounded-3xl overflow-hidden w-fit">
|
||||
<div className="border-1 border-zinc-200 dark:border-zinc-900 rounded-3xl">
|
||||
<CompareSlider
|
||||
onlyHandleDraggable
|
||||
itemOne={
|
||||
mediaFile?.type === 'video'
|
||||
? renderVideo(mediaFile?.path!)
|
||||
: renderImage(mediaFile?.path!)
|
||||
}
|
||||
itemTwo={
|
||||
mediaFile?.type === 'video'
|
||||
? renderVideo(`${mediaFile?.compressedFile?.path!}?id={${id}}`, {
|
||||
isOriginal: false,
|
||||
})
|
||||
: renderImage(`${mediaFile?.compressedFile?.path!}?id={${id}}`, {
|
||||
isOriginal: false,
|
||||
})
|
||||
}
|
||||
handle={<ReactCompareSliderHandle />}
|
||||
style={{ width: 'fit-content' }}
|
||||
/>
|
||||
</div>
|
||||
{mediaFile?.type === 'image' &&
|
||||
mediaFile?.compressedFile?.extension === 'svg' &&
|
||||
(mediaFile?.compressedFile?.sizeInBytes ?? 0) >= 5 * 1024 * 1024 ? (
|
||||
<p className="text-xs my-2 flex justify-center items-center gap-1 text-warning-400">
|
||||
<Icon name="warning" />
|
||||
SVG file too large. Might affect rendering performance.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MediaOutputCompareSlider)
|
||||
|
|
@ -382,31 +382,41 @@ function MediaThumbnail({ mediaIndex }: MediaThumbnailProps) {
|
|||
<div className="relative w-fit mx-auto">
|
||||
<Image
|
||||
alt="video to compress"
|
||||
src={thumbnailPath as string}
|
||||
src={
|
||||
isProcessCompleted
|
||||
? mediaFile?.type === 'video' &&
|
||||
mediaFile?.previewMode === 'image'
|
||||
? mediaFile?.thumbnailPath!
|
||||
: mediaFile?.compressedFile?.path!
|
||||
: (thumbnailPath as string)
|
||||
}
|
||||
className="object-contain rounded-3xl max-h-[65vh] border-1 border-zinc-200 dark:border-zinc-900 min-w-[100px] min-h-[100px]"
|
||||
onError={() => {
|
||||
if (!isProcessCompleted) {
|
||||
handleRegenerateThumbnail('00:00:01.00', 0, true)
|
||||
}
|
||||
}}
|
||||
{...(mediaFile?.dimensions?.width
|
||||
? { width: mediaFile?.dimensions?.width }
|
||||
: {})}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-[20] flex items-center gap-3 bg-zinc-900/50 min-h-[25px] px-3 rounded-2xl">
|
||||
<div className="absolute bottom-3 right-3 z-[20] flex items-center gap-3 bg-zinc-900/50 min-h-[25px] px-2 rounded-2xl">
|
||||
{videoDuration && !isProcessCompleted ? (
|
||||
<Tooltip content="Regenerate Thumbnail" className="w-0! h-0!">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
handleRegenerateThumbnail()
|
||||
}}
|
||||
isDisabled={isThumbnailRegenerating}
|
||||
isLoading={isThumbnailRegenerating}
|
||||
className="!p-0 !min-h-0 !h-[unset] !py-2 !w-[unset] !min-w-[unset] hover:bg-none"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
handleRegenerateThumbnail()
|
||||
}}
|
||||
isDisabled={isThumbnailRegenerating}
|
||||
isLoading={isThumbnailRegenerating}
|
||||
className="!p-0 !min-h-0 !py-2 !w-[unset] !min-w-[unset] !h-0 "
|
||||
>
|
||||
<Tooltip content="Regenerate Thumbnail" className="w-0! h-0!">
|
||||
<Icon name="image" size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
) : null}
|
||||
<ImageViewer
|
||||
// @ts-ignore
|
||||
|
|
@ -418,7 +428,7 @@ function MediaThumbnail({ mediaIndex }: MediaThumbnailProps) {
|
|||
>
|
||||
<PhotoView src={thumbnailPath!}>
|
||||
<div>
|
||||
<Tooltip content="Enlarge Image">
|
||||
<Tooltip content="Enlarge image">
|
||||
<Icon name="zoom" size={18} className="cursor-pointer" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import Button from '@/components/Button'
|
||||
|
|
@ -8,11 +8,14 @@ import Divider from '@/components/Divider'
|
|||
import Icon from '@/components/Icon'
|
||||
import Image from '@/components/Image'
|
||||
import { CircularProgress } from '@/components/Progress'
|
||||
import Switch from '@/components/Switch'
|
||||
import { Ripple } from '@/ui/Patterns/Ripple'
|
||||
import { slideUpTransition, zoomInTransition } from '@/utils/animation'
|
||||
import { formatDuration } from '@/utils/string'
|
||||
import { cn } from '@/utils/tailwind'
|
||||
import { appProxy } from '../-state'
|
||||
import Thumbnail from './MediaThumbnail'
|
||||
import MediaOutputCompareSlider from './MediaOutputCompareSlider'
|
||||
import MediaThumbnail from './MediaThumbnail'
|
||||
import styles from './styles.module.css'
|
||||
import VideoInfo from './VideoInfo'
|
||||
|
||||
|
|
@ -34,12 +37,15 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
|
|||
extension: mediaExtension,
|
||||
compressionProgress,
|
||||
compressedFile,
|
||||
thumbnailPath,
|
||||
} = mediaFile ?? {}
|
||||
const { videoDuration, fps, thumbnailPath } =
|
||||
const { videoDuration, fps } =
|
||||
mediaFile?.type === 'video' ? (mediaFile ?? {}) : {}
|
||||
const { isVideoTransformEditMode, isVideoTrimEditMode } =
|
||||
mediaFile?.type === 'video' ? (mediaFile?.config ?? {}) : {}
|
||||
|
||||
const [compareOutput, setCompareOutput] = useState(true)
|
||||
|
||||
const compressedSizeDiff: number = useMemo(
|
||||
() =>
|
||||
typeof compressedFile?.sizeInBytes === 'number' &&
|
||||
|
|
@ -72,12 +78,40 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
|
|||
</Code>
|
||||
) : null}
|
||||
|
||||
{mediaFile ? <Thumbnail mediaIndex={mediaIndex} /> : null}
|
||||
{isProcessCompleted &&
|
||||
((mediaFile?.type === 'video' && mediaFile?.previewMode === 'video') ||
|
||||
mediaFile?.type === 'image') ? (
|
||||
<div className="flex justify-center my-4">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={compareOutput}
|
||||
onValueChange={() => {
|
||||
setCompareOutput((s) => !s)
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center items-center">
|
||||
<span className="block mr-2 text-sm">Compare</span>
|
||||
<Icon name="compare" />
|
||||
</div>
|
||||
</Switch>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mediaFile ? (
|
||||
isProcessCompleted &&
|
||||
compareOutput &&
|
||||
((mediaFile?.type === 'video' && mediaFile?.previewMode === 'video') ||
|
||||
mediaFile?.type === 'image') ? (
|
||||
<MediaOutputCompareSlider mediaIndex={mediaIndex} />
|
||||
) : (
|
||||
<MediaThumbnail mediaIndex={mediaIndex} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{!(isVideoTransformEditMode || isVideoTrimEditMode) ? (
|
||||
isProcessCompleted ? (
|
||||
<section className="animate-appearance-in">
|
||||
<div className="flex justify-center items-center mt-3">
|
||||
<div className="flex justify-center items-center mt-2">
|
||||
<p className="text-2xl font-bold mx-4">{mediaSize}</p>
|
||||
<Icon
|
||||
name="curvedArrow"
|
||||
|
|
@ -198,12 +232,17 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
|
|||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="w-full h-full flex flex-col justify-center items-center flex-shrink-0"
|
||||
className="relative w-full h-full flex flex-col justify-center items-center flex-shrink-0"
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', duration: 0.6 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<Ripple
|
||||
mainCircleOpacity={0.2}
|
||||
mainCircleSize={380}
|
||||
className="absolute top-0 left-0"
|
||||
/>
|
||||
<CircularProgress
|
||||
{...(videoDuration == null
|
||||
? { isIndeterminate: true }
|
||||
|
|
|
|||
|
|
@ -791,7 +791,7 @@ function SubtitleStreamsDisplay({
|
|||
|
||||
{stream.disposition.default ||
|
||||
stream.disposition.forced ||
|
||||
stream.disposition.attached_pic ||
|
||||
stream.disposition.attachedPic ||
|
||||
stream.disposition.comment ||
|
||||
stream.disposition.karaoke ||
|
||||
stream.disposition.lyrics ? (
|
||||
|
|
@ -808,7 +808,7 @@ function SubtitleStreamsDisplay({
|
|||
- Forced
|
||||
</div>
|
||||
) : null}
|
||||
{stream.disposition.attached_pic ? (
|
||||
{stream.disposition.attachedPic ? (
|
||||
<div className="text-zinc-600 dark:text-zinc-400 text-xs">
|
||||
- Attached Picture
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import Icon from '@/components/Icon'
|
|||
import Markdown from '@/components/Markdown'
|
||||
import Modal, { ModalContent } from '@/components/Modal'
|
||||
import ScrollShadow from '@/components/ScrollShadow'
|
||||
import ThemeSwitcher from '@/components/ThemeSwitcher'
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher'
|
||||
import Title from '@/components/Title'
|
||||
import { toast } from '@/components/Toast'
|
||||
import Tooltip from '@/components/Tooltip'
|
||||
|
|
|
|||
58
src/ui/Patterns/Ripple.tsx
Normal file
58
src/ui/Patterns/Ripple.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { type ComponentPropsWithoutRef, type CSSProperties } from 'react'
|
||||
|
||||
import { cn } from '@/utils/tailwind'
|
||||
|
||||
interface RippleProps extends ComponentPropsWithoutRef<'div'> {
|
||||
mainCircleSize?: number
|
||||
mainCircleOpacity?: number
|
||||
numCircles?: number
|
||||
}
|
||||
|
||||
export const Ripple = React.memo(function Ripple({
|
||||
mainCircleSize = 210,
|
||||
mainCircleOpacity = 0.24,
|
||||
numCircles = 8,
|
||||
className,
|
||||
...props
|
||||
}: RippleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 mask-[linear-gradient(to_bottom,white,transparent)] select-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: numCircles }, (_, i) => {
|
||||
const size = mainCircleSize + i * 70
|
||||
const opacity = mainCircleOpacity - i * 0.03
|
||||
const animationDelay = `${i * 0.06}s`
|
||||
const borderStyle = 'solid'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`animate-ripple bg-foreground/25 absolute rounded-full border shadow-xl`}
|
||||
style={
|
||||
{
|
||||
'--i': i,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
opacity,
|
||||
animationDelay,
|
||||
borderStyle,
|
||||
borderWidth: '1px',
|
||||
borderColor: `var(--foreground)`,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) scale(1)',
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Ripple.displayName = 'Ripple'
|
||||
|
|
@ -39,6 +39,9 @@ const config: Config = {
|
|||
],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
ripple: 'var(--animate-ripple)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
|
|
|
|||
Loading…
Reference in a new issue