mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Selectable wall preview type (#510)
* Add optional image preview generation * Add setting for video preview encoding preset
This commit is contained in:
@@ -21,6 +21,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||
soundOnPreview
|
||||
wallShowTitle
|
||||
wallPlayback
|
||||
maximumLoopDuration
|
||||
autostartVideo
|
||||
showStudioAsText
|
||||
|
||||
@@ -86,6 +86,8 @@ input ConfigInterfaceInput {
|
||||
soundOnPreview: Boolean
|
||||
"""Show title and tags in wall view"""
|
||||
wallShowTitle: Boolean
|
||||
"""Wall playback type"""
|
||||
wallPlayback: String
|
||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||
maximumLoopDuration: Int
|
||||
"""If true, video will autostart on load in the scene player"""
|
||||
@@ -103,6 +105,8 @@ type ConfigInterfaceResult {
|
||||
soundOnPreview: Boolean
|
||||
"""Show title and tags in wall view"""
|
||||
wallShowTitle: Boolean
|
||||
"""Wall playback type"""
|
||||
wallPlayback: String
|
||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||
maximumLoopDuration: Int
|
||||
"""If true, video will autostart on load in the scene player"""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
input GenerateMetadataInput {
|
||||
sprites: Boolean!
|
||||
previews: Boolean!
|
||||
previewPreset: PreviewPreset
|
||||
imagePreviews: Boolean!
|
||||
markers: Boolean!
|
||||
transcodes: Boolean!
|
||||
"""gallery thumbnails for cache usage"""
|
||||
@@ -22,6 +24,16 @@ input AutoTagMetadataInput {
|
||||
|
||||
type MetadataUpdateStatus {
|
||||
progress: Float!
|
||||
status: String!
|
||||
status: String!
|
||||
message: String!
|
||||
}
|
||||
|
||||
enum PreviewPreset {
|
||||
"X264_ULTRAFAST", ultrafast
|
||||
"X264_VERYFAST", veryfast
|
||||
"X264_FAST", fast
|
||||
"X264_MEDIUM", medium
|
||||
"X264_SLOW", slow
|
||||
"X264_SLOWER", slower
|
||||
"X264_VERYSLOW", veryslow
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
||||
config.Set(config.WallShowTitle, *input.WallShowTitle)
|
||||
}
|
||||
|
||||
if input.WallPlayback != nil {
|
||||
config.Set(config.WallPlayback, *input.WallPlayback)
|
||||
}
|
||||
|
||||
if input.MaximumLoopDuration != nil {
|
||||
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MetadataGenerate(ctx context.Context, input models.GenerateMetadataInput) (string, error) {
|
||||
manager.GetInstance().Generate(input.Sprites, input.Previews, input.Markers, input.Transcodes, input.Thumbnails)
|
||||
manager.GetInstance().Generate(input.Sprites, input.Previews, input.PreviewPreset, input.ImagePreviews, input.Markers, input.Transcodes, input.Thumbnails)
|
||||
return "todo", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
||||
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
soundOnPreview := config.GetSoundOnPreview()
|
||||
wallShowTitle := config.GetWallShowTitle()
|
||||
wallPlayback := config.GetWallPlayback()
|
||||
maximumLoopDuration := config.GetMaximumLoopDuration()
|
||||
autostartVideo := config.GetAutostartVideo()
|
||||
showStudioAsText := config.GetShowStudioAsText()
|
||||
@@ -75,6 +76,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
return &models.ConfigInterfaceResult{
|
||||
SoundOnPreview: &soundOnPreview,
|
||||
WallShowTitle: &wallShowTitle,
|
||||
WallPlayback: &wallPlayback,
|
||||
MaximumLoopDuration: &maximumLoopDuration,
|
||||
AutostartVideo: &autostartVideo,
|
||||
ShowStudioAsText: &showStudioAsText,
|
||||
|
||||
@@ -13,9 +13,10 @@ type ScenePreviewChunkOptions struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions) {
|
||||
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string) {
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-xerror",
|
||||
"-ss", strconv.Itoa(options.Time),
|
||||
"-i", probeResult.Path,
|
||||
"-t", "0.75",
|
||||
@@ -25,7 +26,7 @@ func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePre
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", "slow",
|
||||
"-preset", preset,
|
||||
"-crf", "21",
|
||||
"-threads", "4",
|
||||
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
)
|
||||
|
||||
@@ -264,7 +265,11 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
||||
result.Container = probeJSON.Format.FormatName
|
||||
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
||||
result.Duration = math.Round(duration*100) / 100
|
||||
fileStat, _ := os.Stat(filePath)
|
||||
fileStat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Errorf("Error statting file: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
result.Size = fileStat.Size()
|
||||
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
|
||||
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
|
||||
|
||||
@@ -54,6 +54,7 @@ const MaximumLoopDuration = "maximum_loop_duration"
|
||||
const AutostartVideo = "autostart_video"
|
||||
const ShowStudioAsText = "show_studio_as_text"
|
||||
const CSSEnabled = "cssEnabled"
|
||||
const WallPlayback = "wall_playback"
|
||||
|
||||
// Playback force codec,container
|
||||
const ForceMKV = "forceMKV"
|
||||
@@ -241,6 +242,11 @@ func GetWallShowTitle() bool {
|
||||
return viper.GetBool(WallShowTitle)
|
||||
}
|
||||
|
||||
func GetWallPlayback() string {
|
||||
viper.SetDefault(WallPlayback, "video")
|
||||
return viper.GetString(WallPlayback)
|
||||
}
|
||||
|
||||
func GetMaximumLoopDuration() int {
|
||||
viper.SetDefault(MaximumLoopDuration, 0)
|
||||
return viper.GetInt(MaximumLoopDuration)
|
||||
|
||||
@@ -16,9 +16,14 @@ type PreviewGenerator struct {
|
||||
VideoFilename string
|
||||
ImageFilename string
|
||||
OutputDirectory string
|
||||
|
||||
GenerateVideo bool
|
||||
GenerateImage bool
|
||||
|
||||
PreviewPreset string
|
||||
}
|
||||
|
||||
func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string) (*PreviewGenerator, error) {
|
||||
func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string, generateVideo bool, generateImage bool, previewPreset string) (*PreviewGenerator, error) {
|
||||
exists, err := utils.FileExists(videoFile.Path)
|
||||
if !exists {
|
||||
return nil, err
|
||||
@@ -37,6 +42,9 @@ func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, image
|
||||
VideoFilename: videoFilename,
|
||||
ImageFilename: imageFilename,
|
||||
OutputDirectory: outputDirectory,
|
||||
GenerateVideo: generateVideo,
|
||||
GenerateImage: generateImage,
|
||||
PreviewPreset: previewPreset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -47,11 +55,16 @@ func (g *PreviewGenerator) Generate() error {
|
||||
if err := g.generateConcatFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.generateVideo(&encoder); err != nil {
|
||||
return err
|
||||
|
||||
if g.GenerateVideo {
|
||||
if err := g.generateVideo(&encoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := g.generateImage(&encoder); err != nil {
|
||||
return err
|
||||
if g.GenerateImage {
|
||||
if err := g.generateImage(&encoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -91,7 +104,7 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error {
|
||||
Width: 640,
|
||||
OutputPath: chunkOutputPath,
|
||||
}
|
||||
encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options)
|
||||
encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset)
|
||||
}
|
||||
|
||||
videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename)
|
||||
|
||||
@@ -171,7 +171,7 @@ func (s *singleton) Export() {
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcodes bool, thumbnails bool) {
|
||||
func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models.PreviewPreset, imagePreviews bool, markers bool, transcodes bool, thumbnails bool) {
|
||||
if s.Status.Status != Idle {
|
||||
return
|
||||
}
|
||||
@@ -183,6 +183,11 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
|
||||
//this.job.total = await ObjectionUtils.getCount(Scene);
|
||||
instance.Paths.Generated.EnsureTmpDir()
|
||||
|
||||
preset := string(models.PreviewPresetSlow)
|
||||
if previewPreset != nil && previewPreset.IsValid() {
|
||||
preset = string(*previewPreset)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer s.returnToIdleState()
|
||||
|
||||
@@ -212,12 +217,12 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
|
||||
logger.Info("Stopping due to user request")
|
||||
return
|
||||
}
|
||||
totalsNeeded := s.neededGenerate(scenes, sprites, previews, markers, transcodes)
|
||||
totalsNeeded := s.neededGenerate(scenes, sprites, previews, imagePreviews, markers, transcodes)
|
||||
if totalsNeeded == nil {
|
||||
logger.Infof("Taking too long to count content. Skipping...")
|
||||
logger.Infof("Generating content")
|
||||
} else {
|
||||
logger.Infof("Generating %d sprites %d previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.markers, totalsNeeded.transcodes)
|
||||
logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.imagePreviews, totalsNeeded.markers, totalsNeeded.transcodes)
|
||||
}
|
||||
for i, scene := range scenes {
|
||||
s.Status.setProgress(i, total)
|
||||
@@ -244,7 +249,7 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
|
||||
}
|
||||
|
||||
if previews {
|
||||
task := GeneratePreviewTask{Scene: *scene}
|
||||
task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews, PreviewPreset: preset}
|
||||
go task.Start(&wg)
|
||||
}
|
||||
|
||||
@@ -602,13 +607,14 @@ func (s *singleton) neededScan(paths []string) int64 {
|
||||
}
|
||||
|
||||
type totalsGenerate struct {
|
||||
sprites int64
|
||||
previews int64
|
||||
markers int64
|
||||
transcodes int64
|
||||
sprites int64
|
||||
previews int64
|
||||
imagePreviews int64
|
||||
markers int64
|
||||
transcodes int64
|
||||
}
|
||||
|
||||
func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, markers, transcodes bool) *totalsGenerate {
|
||||
func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, imagePreviews, markers, transcodes bool) *totalsGenerate {
|
||||
|
||||
var totals totalsGenerate
|
||||
const timeout = 90 * time.Second
|
||||
@@ -633,10 +639,13 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, ma
|
||||
}
|
||||
|
||||
if previews {
|
||||
task := GeneratePreviewTask{Scene: *scene}
|
||||
if !task.doesPreviewExist(task.Scene.Checksum) {
|
||||
task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews}
|
||||
if !task.doesVideoPreviewExist(task.Scene.Checksum) {
|
||||
totals.previews++
|
||||
}
|
||||
if imagePreviews && !task.doesImagePreviewExist(task.Scene.Checksum) {
|
||||
totals.imagePreviews++
|
||||
}
|
||||
}
|
||||
|
||||
if markers {
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
)
|
||||
|
||||
type GeneratePreviewTask struct {
|
||||
Scene models.Scene
|
||||
Scene models.Scene
|
||||
ImagePreview bool
|
||||
PreviewPreset string
|
||||
}
|
||||
|
||||
func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
||||
@@ -17,7 +19,8 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
||||
|
||||
videoFilename := t.videoFilename()
|
||||
imageFilename := t.imageFilename()
|
||||
if t.doesPreviewExist(t.Scene.Checksum) {
|
||||
videoExists := t.doesVideoPreviewExist(t.Scene.Checksum)
|
||||
if (!t.ImagePreview || t.doesImagePreviewExist(t.Scene.Checksum)) && videoExists {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots)
|
||||
generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, !videoExists, t.ImagePreview, t.PreviewPreset)
|
||||
if err != nil {
|
||||
logger.Errorf("error creating preview generator: %s", err.Error())
|
||||
return
|
||||
@@ -39,10 +42,14 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GeneratePreviewTask) doesPreviewExist(sceneChecksum string) bool {
|
||||
func (t *GeneratePreviewTask) doesVideoPreviewExist(sceneChecksum string) bool {
|
||||
videoExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewPath(sceneChecksum))
|
||||
return videoExists
|
||||
}
|
||||
|
||||
func (t *GeneratePreviewTask) doesImagePreviewExist(sceneChecksum string) bool {
|
||||
imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum))
|
||||
return videoExists && imageExists
|
||||
return imageExists
|
||||
}
|
||||
|
||||
func (t *GeneratePreviewTask) videoFilename() string {
|
||||
|
||||
@@ -28,11 +28,8 @@ func FileExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
} else if os.IsNotExist(err) {
|
||||
return false, err
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// DirExists returns true if the given path exists and is a directory
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
const { data: config, error, loading } = useConfiguration();
|
||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
||||
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
||||
@@ -19,6 +20,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
const [updateInterfaceConfig] = useConfigureInterface({
|
||||
soundOnPreview,
|
||||
wallShowTitle,
|
||||
wallPlayback,
|
||||
maximumLoopDuration,
|
||||
autostartVideo,
|
||||
showStudioAsText,
|
||||
@@ -31,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
const iCfg = config?.configuration?.interface;
|
||||
setSoundOnPreview(iCfg?.soundOnPreview ?? true);
|
||||
setWallShowTitle(iCfg?.wallShowTitle ?? true);
|
||||
setWallPlayback(iCfg?.wallPlayback ?? "video");
|
||||
setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
|
||||
setAutostartVideo(iCfg?.autostartVideo ?? false);
|
||||
setShowStudioAsText(iCfg?.showStudioAsText ?? false);
|
||||
@@ -85,6 +88,22 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
label="Enable sound"
|
||||
onChange={() => setSoundOnPreview(!soundOnPreview)}
|
||||
/>
|
||||
<Form.Label htmlFor="wall-preview">
|
||||
<h6>Preview Type</h6>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="wall-preview"
|
||||
className="col-4 input-control"
|
||||
value={wallPlayback}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setWallPlayback(e.currentTarget.value)
|
||||
}
|
||||
>
|
||||
<option value="video">Video</option>
|
||||
<option value="animation">Animated Image</option>
|
||||
<option value="image">Static Image</option>
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">
|
||||
Configuration for wall items
|
||||
</Form.Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { mutateMetadataGenerate } from "src/core/StashService";
|
||||
import { PreviewPreset } from "src/core/generated-graphql";
|
||||
import { useToast } from "src/hooks";
|
||||
|
||||
export const GenerateButton: React.FC = () => {
|
||||
@@ -10,15 +11,21 @@ export const GenerateButton: React.FC = () => {
|
||||
const [markers, setMarkers] = useState(true);
|
||||
const [transcodes, setTranscodes] = useState(false);
|
||||
const [thumbnails, setThumbnails] = useState(false);
|
||||
const [imagePreviews, setImagePreviews] = useState(false);
|
||||
const [previewPreset, setPreviewPreset] = useState<string>(
|
||||
PreviewPreset.Slow
|
||||
);
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
await mutateMetadataGenerate({
|
||||
sprites,
|
||||
previews,
|
||||
imagePreviews: previews && imagePreviews,
|
||||
markers,
|
||||
transcodes,
|
||||
thumbnails,
|
||||
previewPreset: (previewPreset as PreviewPreset) ?? undefined,
|
||||
});
|
||||
Toast.success({ content: "Started generating" });
|
||||
} catch (e) {
|
||||
@@ -29,18 +36,54 @@ export const GenerateButton: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="sprite-task"
|
||||
checked={sprites}
|
||||
label="Sprites (for the scene scrubber)"
|
||||
onChange={() => setSprites(!sprites)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="preview-task"
|
||||
checked={previews}
|
||||
label="Previews (video previews which play when hovering over a scene)"
|
||||
onChange={() => setPreviews(!previews)}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<Form.Check
|
||||
id="image-preview-task"
|
||||
checked={imagePreviews}
|
||||
disabled={!previews}
|
||||
label="Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)"
|
||||
onChange={() => setImagePreviews(!imagePreviews)}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<Form.Group controlId="preview-preset" className="mt-2">
|
||||
<Form.Label>
|
||||
<h6>Preview encoding preset</h6>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={previewPreset}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setPreviewPreset(e.currentTarget.value)
|
||||
}
|
||||
disabled={!previews}
|
||||
className="col-1"
|
||||
>
|
||||
{Object.keys(PreviewPreset).map((p) => (
|
||||
<option value={p.toLowerCase()} key={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">
|
||||
The preset regulates size, quality and encoding time of preview
|
||||
generation. Presets beyond “slow” have diminishing returns and are
|
||||
not recommended.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
id="sprite-task"
|
||||
checked={sprites}
|
||||
label="Sprites (for the scene scrubber)"
|
||||
onChange={() => setSprites(!sprites)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="marker-task"
|
||||
checked={markers}
|
||||
|
||||
@@ -1,64 +1,139 @@
|
||||
import _ from "lodash";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useConfiguration } from "src/core/StashService";
|
||||
import { useVideoHover } from "src/hooks";
|
||||
import { TextUtils, NavUtils } from "src/utils";
|
||||
import cx from "classnames";
|
||||
|
||||
interface IWallItemProps {
|
||||
scene?: GQL.SlimSceneDataFragment;
|
||||
sceneMarker?: GQL.SceneMarkerDataFragment;
|
||||
origin?: string;
|
||||
onOverlay: (show: boolean) => void;
|
||||
clickHandler?: (
|
||||
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
|
||||
) => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface IPreviews {
|
||||
video?: string;
|
||||
animation?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const Preview: React.FC<{
|
||||
previews?: IPreviews;
|
||||
config?: GQL.ConfigDataFragment;
|
||||
active: boolean;
|
||||
}> = ({ previews, config, active }) => {
|
||||
const videoElement = useRef() as React.MutableRefObject<HTMLVideoElement>;
|
||||
const [isMissing, setIsMissing] = useState(false);
|
||||
|
||||
const previewType = config?.interface?.wallPlayback;
|
||||
const soundOnPreview = config?.interface?.soundOnPreview ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoElement.current) return;
|
||||
videoElement.current.muted = !(soundOnPreview && active);
|
||||
if (previewType !== "video") {
|
||||
if (active) videoElement.current.play();
|
||||
else videoElement.current.pause();
|
||||
}
|
||||
}, [videoElement, previewType, soundOnPreview, active]);
|
||||
|
||||
if (!previews) return <div />;
|
||||
|
||||
if (isMissing) {
|
||||
return (
|
||||
<div className="wall-item-media wall-item-missing">
|
||||
Pending preview generation
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<img
|
||||
alt=""
|
||||
className="wall-item-media"
|
||||
src={
|
||||
(previewType === "animation" && previews.animation) || previews.image
|
||||
}
|
||||
/>
|
||||
);
|
||||
const video = (
|
||||
<video
|
||||
src={previews.video}
|
||||
poster={previews.image}
|
||||
autoPlay={previewType === "video"}
|
||||
loop
|
||||
muted
|
||||
className={cx("wall-item-media", {
|
||||
"wall-item-preview": previewType !== "video",
|
||||
})}
|
||||
onError={() => setIsMissing(true)}
|
||||
ref={videoElement}
|
||||
/>
|
||||
);
|
||||
|
||||
if (previewType === "video") {
|
||||
return video;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{image}
|
||||
{video}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
|
||||
const [videoPath, setVideoPath] = useState<string>();
|
||||
const [previewPath, setPreviewPath] = useState<string>("");
|
||||
const [screenshotPath, setScreenshotPath] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [tags, setTags] = useState<JSX.Element[]>([]);
|
||||
const [active, setActive] = useState(false);
|
||||
const wallItem = useRef() as React.MutableRefObject<HTMLDivElement>;
|
||||
const config = useConfiguration();
|
||||
const hoverHandler = useVideoHover({
|
||||
resetOnMouseLeave: true,
|
||||
});
|
||||
|
||||
const showTextContainer =
|
||||
config.data?.configuration.interface.wallShowTitle ?? true;
|
||||
|
||||
function onMouseEnter() {
|
||||
hoverHandler.onMouseEnter();
|
||||
if (!videoPath || videoPath === "") {
|
||||
if (props.sceneMarker) {
|
||||
setVideoPath(props.sceneMarker.stream || "");
|
||||
} else if (props.scene) {
|
||||
setVideoPath(props.scene.paths.preview || "");
|
||||
const previews = props.sceneMarker
|
||||
? {
|
||||
video: props.sceneMarker.stream,
|
||||
animation: props.sceneMarker.preview,
|
||||
}
|
||||
}
|
||||
props.onOverlay(true);
|
||||
}
|
||||
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
|
||||
: {
|
||||
video: props.scene?.paths.preview ?? undefined,
|
||||
animation: props.scene?.paths.webp ?? undefined,
|
||||
image: props.scene?.paths.screenshot ?? undefined,
|
||||
};
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverHandler.onMouseLeave();
|
||||
setVideoPath("");
|
||||
debouncedOnMouseEnter.current.cancel();
|
||||
props.onOverlay(false);
|
||||
}
|
||||
const setInactive = () => setActive(false);
|
||||
const toggleActive = (e: TransitionEvent) => {
|
||||
if (e.propertyName === "transform" && e.elapsedTime === 0) {
|
||||
// Get the current scale of the wall-item. If it's smaller than 1.1 the item is being scaled up, otherwise down.
|
||||
const matrixScale = getComputedStyle(wallItem.current).transform.match(
|
||||
/-?\d+\.?\d+|\d+/g
|
||||
)?.[0];
|
||||
const scale = Number.parseFloat(matrixScale ?? "2") || 2;
|
||||
setActive(scale <= 1.1 && !active);
|
||||
}
|
||||
};
|
||||
|
||||
function onClick() {
|
||||
if (props.clickHandler === undefined) {
|
||||
return;
|
||||
useEffect(() => {
|
||||
const { current } = wallItem;
|
||||
current?.addEventListener("transitioncancel", setInactive);
|
||||
current?.addEventListener("transitionstart", toggleActive);
|
||||
return () => {
|
||||
current?.removeEventListener("transitioncancel", setInactive);
|
||||
current?.removeEventListener("transitionstart", toggleActive);
|
||||
};
|
||||
});
|
||||
|
||||
const clickHandler = () => {
|
||||
if (props.scene) {
|
||||
props?.clickHandler?.(props.scene);
|
||||
}
|
||||
if (props.scene !== undefined) {
|
||||
props.clickHandler(props.scene);
|
||||
} else if (props.sceneMarker !== undefined) {
|
||||
props.clickHandler(props.sceneMarker);
|
||||
if (props.sceneMarker) {
|
||||
props?.clickHandler?.(props.sceneMarker);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let linkSrc: string = "#";
|
||||
if (!props.clickHandler) {
|
||||
@@ -69,89 +144,40 @@ export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
|
||||
const target = event.currentTarget;
|
||||
if (target.classList.contains("double-scale") && target.parentElement) {
|
||||
target.parentElement.style.zIndex = "10";
|
||||
} else if (target.parentElement) {
|
||||
target.parentElement.style.zIndex = "";
|
||||
}
|
||||
}
|
||||
const renderText = () => {
|
||||
if (!showTextContainer) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.sceneMarker) {
|
||||
setPreviewPath(props.sceneMarker.preview);
|
||||
setTitle(
|
||||
`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
|
||||
const title = props.sceneMarker
|
||||
? `${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
|
||||
props.sceneMarker.seconds
|
||||
)}`
|
||||
);
|
||||
const thisTags = props.sceneMarker.tags.map((tag) => (
|
||||
<span key={tag.id} className="wall-tag">
|
||||
{tag.name}
|
||||
</span>
|
||||
));
|
||||
thisTags.unshift(
|
||||
<span key={props.sceneMarker.primary_tag.id} className="wall-tag">
|
||||
{props.sceneMarker.primary_tag.name}
|
||||
</span>
|
||||
);
|
||||
setTags(thisTags);
|
||||
} else if (props.scene) {
|
||||
setPreviewPath(props.scene.paths.webp || "");
|
||||
setScreenshotPath(props.scene.paths.screenshot || "");
|
||||
setTitle(props.scene.title || "");
|
||||
}
|
||||
}, [props.sceneMarker, props.scene]);
|
||||
: props.scene?.title ?? "";
|
||||
const tags = props.sceneMarker
|
||||
? [props.sceneMarker.primary_tag, ...props.sceneMarker.tags]
|
||||
: [];
|
||||
|
||||
function previewNotFound() {
|
||||
if (previewPath !== screenshotPath) {
|
||||
setPreviewPath(screenshotPath);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="wall-item-text">
|
||||
<div>{title}</div>
|
||||
{tags.map((tag) => (
|
||||
<span key={tag.id} className="wall-tag">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const className = ["scene-wall-item-container"];
|
||||
if (hoverHandler.isHovering.current) {
|
||||
className.push("double-scale");
|
||||
}
|
||||
const style: React.CSSProperties = {};
|
||||
if (props.origin) {
|
||||
style.transformOrigin = props.origin;
|
||||
}
|
||||
return (
|
||||
<div className="wall-item">
|
||||
<div
|
||||
className={className.join(" ")}
|
||||
style={style}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
onMouseEnter={() => debouncedOnMouseEnter.current()}
|
||||
onMouseMove={() => debouncedOnMouseEnter.current()}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Link onClick={onClick} to={linkSrc}>
|
||||
<video
|
||||
src={videoPath}
|
||||
poster={screenshotPath}
|
||||
className="scene-wall-video"
|
||||
style={hoverHandler.isHovering.current ? {} : { display: "none" }}
|
||||
autoPlay
|
||||
loop
|
||||
ref={hoverHandler.videoEl}
|
||||
<div className={`wall-item-container ${props.className}`} ref={wallItem}>
|
||||
<Link onClick={clickHandler} to={linkSrc} className="wall-item-anchor">
|
||||
<Preview
|
||||
previews={previews}
|
||||
config={config.data?.configuration}
|
||||
active={active}
|
||||
/>
|
||||
<img
|
||||
alt={title}
|
||||
className="scene-wall-image"
|
||||
src={previewPath || screenshotPath}
|
||||
onError={() => previewNotFound()}
|
||||
/>
|
||||
{showTextContainer ? (
|
||||
<div className="scene-wall-item-text-container">
|
||||
<div>{title}</div>
|
||||
{tags}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{renderText()}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { WallItem } from "./WallItem";
|
||||
|
||||
@@ -10,106 +10,58 @@ interface IWallPanelProps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
const calculateClass = (index: number, count: number) => {
|
||||
// First position and more than one row
|
||||
if (index === 0 && count > 5) return "transform-origin-top-left";
|
||||
// Fifth position and more than one row
|
||||
if (index === 4 && count > 5) return "transform-origin-top-right";
|
||||
// Top row
|
||||
if (index < 5) return "transform-origin-top";
|
||||
// Two or more rows, with full last row and index is last
|
||||
if (count > 9 && count % 5 === 0 && index + 1 === count)
|
||||
return "transform-origin-bottom-right";
|
||||
// Two or more rows, with full last row and index is fifth to last
|
||||
if (count > 9 && count % 5 === 0 && index + 5 === count)
|
||||
return "transform-origin-bottom-left";
|
||||
// Multiple of five minus one
|
||||
if (index % 5 === 4) return "transform-origin-right";
|
||||
// Multiple of five
|
||||
if (index % 5 === 0) return "transform-origin-left";
|
||||
// Position is equal or larger than first postion in last row
|
||||
if (count - (count % 5 || 5) <= index + 1) return "transform-origin-bottom";
|
||||
// Default
|
||||
return "transform-origin-center";
|
||||
};
|
||||
|
||||
export const WallPanel: React.FC<IWallPanelProps> = (
|
||||
props: IWallPanelProps
|
||||
) => {
|
||||
const [showOverlay, setShowOverlay] = useState<boolean>(false);
|
||||
const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => (
|
||||
<WallItem
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
clickHandler={props.clickHandler}
|
||||
className={calculateClass(index, sceneArray.length)}
|
||||
/>
|
||||
));
|
||||
|
||||
function onOverlay(show: boolean) {
|
||||
setShowOverlay(show);
|
||||
}
|
||||
const sceneMarkers = (
|
||||
props.sceneMarkers ?? []
|
||||
).map((marker, index, markerArray) => (
|
||||
<WallItem
|
||||
key={marker.id}
|
||||
sceneMarker={marker}
|
||||
clickHandler={props.clickHandler}
|
||||
className={calculateClass(index, markerArray.length)}
|
||||
/>
|
||||
));
|
||||
|
||||
function getOrigin(index: number, rowSize: number, total: number): string {
|
||||
const isAtStart = index % rowSize === 0;
|
||||
const isAtEnd = index % rowSize === rowSize - 1;
|
||||
const endRemaining = total % rowSize;
|
||||
|
||||
// First row
|
||||
if (total === 1) {
|
||||
return "top";
|
||||
}
|
||||
if (index === 0) {
|
||||
return "top left";
|
||||
}
|
||||
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) {
|
||||
return "top right";
|
||||
}
|
||||
if (index < rowSize) {
|
||||
return "top";
|
||||
}
|
||||
|
||||
// Bottom row
|
||||
if (isAtEnd && index === total - 1) {
|
||||
return "bottom right";
|
||||
}
|
||||
if (isAtStart && index === total - rowSize) {
|
||||
return "bottom left";
|
||||
}
|
||||
if (endRemaining !== 0 && index >= total - endRemaining) {
|
||||
return "bottom";
|
||||
}
|
||||
if (endRemaining === 0 && index >= total - rowSize) {
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
// Everything else
|
||||
if (isAtStart) {
|
||||
return "center left";
|
||||
}
|
||||
if (isAtEnd) {
|
||||
return "center right";
|
||||
}
|
||||
return "center";
|
||||
}
|
||||
|
||||
function maybeRenderScenes() {
|
||||
if (props.scenes === undefined) {
|
||||
return;
|
||||
}
|
||||
return props.scenes.map((scene, index) => {
|
||||
const origin = getOrigin(index, 5, props.scenes!.length);
|
||||
return (
|
||||
<WallItem
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
onOverlay={onOverlay}
|
||||
clickHandler={props.clickHandler}
|
||||
origin={origin}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeRenderSceneMarkers() {
|
||||
if (props.sceneMarkers === undefined) {
|
||||
return;
|
||||
}
|
||||
return props.sceneMarkers.map((marker, index) => {
|
||||
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
|
||||
return (
|
||||
<WallItem
|
||||
key={marker.id}
|
||||
sceneMarker={marker}
|
||||
onOverlay={onOverlay}
|
||||
clickHandler={props.clickHandler}
|
||||
origin={origin}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const overlayClassName = showOverlay ? "visible" : "hidden";
|
||||
return (
|
||||
<>
|
||||
<div className={`wall-overlay ${overlayClassName}`} />
|
||||
<div className="wall row justify-content-center">
|
||||
{maybeRenderScenes()}
|
||||
{maybeRenderSceneMarkers()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="wall w-100 row justify-content-center">
|
||||
{scenes}
|
||||
{sceneMarkers}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,111 +1,162 @@
|
||||
.wall-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.visible-unanimated {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hidden-unanimated {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.double-scale {
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
transform: scale(2);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.double-scale .scene-wall-image {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene-wall-video,
|
||||
.scene-wall-image {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-wall-item-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
max-height: 253px;
|
||||
position: relative;
|
||||
transition: transform 0.5s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-wall-item-container .scene-wall-video {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scene-wall-item-text-container {
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.25),
|
||||
rgba(255, 255, 255, 0.65)
|
||||
);
|
||||
bottom: 0;
|
||||
color: #444;
|
||||
font-weight: 700;
|
||||
left: 0;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.wall-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-wall-item-blur {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
top: -5px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.wall {
|
||||
.wall-item {
|
||||
margin: 0 auto;
|
||||
max-width: 2250px;
|
||||
|
||||
&-item {
|
||||
height: 11.25vw;
|
||||
line-height: 0;
|
||||
max-height: 253px;
|
||||
max-width: 450px;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: z-index 0.5s 0.5s;
|
||||
width: 20%;
|
||||
z-index: 0;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
height: inherit;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-anchor:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&-media {
|
||||
background-color: black;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: z-index 0s 0s;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&-missing {
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
display: flex;
|
||||
font-size: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: z-index 0s 0s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&-text {
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.25),
|
||||
rgba(255, 255, 255, 0.65)
|
||||
);
|
||||
bottom: 0;
|
||||
color: #444;
|
||||
font-weight: 700;
|
||||
left: 0;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
z-index: 2000000;
|
||||
|
||||
.wall-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&-container {
|
||||
background-color: black;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: all 0.5s 0s;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&-container.transform-origin-top-left {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
&-container.transform-origin-top-right {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
&-container.transform-origin-bottom-left {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
&-container.transform-origin-bottom-right {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
&-container.transform-origin-left {
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
&-container.transform-origin-right {
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
&-container.transform-origin-top {
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
&-container.transform-origin-bottom {
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
&-container.transform-origin-center {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: black;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: opacity 0.5s 0s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
&:hover {
|
||||
z-index: 2;
|
||||
|
||||
.wall-item-media {
|
||||
transition-delay: 0.5s;
|
||||
transition-duration: 0.5s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: 0.8;
|
||||
transition-delay: 0.5s;
|
||||
}
|
||||
|
||||
.wall-item-container {
|
||||
background-color: black;
|
||||
position: relative;
|
||||
transform: scale(2);
|
||||
transition-delay: 0.5s;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user