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 {
|
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||||
soundOnPreview
|
soundOnPreview
|
||||||
wallShowTitle
|
wallShowTitle
|
||||||
|
wallPlayback
|
||||||
maximumLoopDuration
|
maximumLoopDuration
|
||||||
autostartVideo
|
autostartVideo
|
||||||
showStudioAsText
|
showStudioAsText
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ input ConfigInterfaceInput {
|
|||||||
soundOnPreview: Boolean
|
soundOnPreview: Boolean
|
||||||
"""Show title and tags in wall view"""
|
"""Show title and tags in wall view"""
|
||||||
wallShowTitle: Boolean
|
wallShowTitle: Boolean
|
||||||
|
"""Wall playback type"""
|
||||||
|
wallPlayback: String
|
||||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||||
maximumLoopDuration: Int
|
maximumLoopDuration: Int
|
||||||
"""If true, video will autostart on load in the scene player"""
|
"""If true, video will autostart on load in the scene player"""
|
||||||
@@ -103,6 +105,8 @@ type ConfigInterfaceResult {
|
|||||||
soundOnPreview: Boolean
|
soundOnPreview: Boolean
|
||||||
"""Show title and tags in wall view"""
|
"""Show title and tags in wall view"""
|
||||||
wallShowTitle: Boolean
|
wallShowTitle: Boolean
|
||||||
|
"""Wall playback type"""
|
||||||
|
wallPlayback: String
|
||||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||||
maximumLoopDuration: Int
|
maximumLoopDuration: Int
|
||||||
"""If true, video will autostart on load in the scene player"""
|
"""If true, video will autostart on load in the scene player"""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
input GenerateMetadataInput {
|
input GenerateMetadataInput {
|
||||||
sprites: Boolean!
|
sprites: Boolean!
|
||||||
previews: Boolean!
|
previews: Boolean!
|
||||||
|
previewPreset: PreviewPreset
|
||||||
|
imagePreviews: Boolean!
|
||||||
markers: Boolean!
|
markers: Boolean!
|
||||||
transcodes: Boolean!
|
transcodes: Boolean!
|
||||||
"""gallery thumbnails for cache usage"""
|
"""gallery thumbnails for cache usage"""
|
||||||
@@ -25,3 +27,13 @@ type MetadataUpdateStatus {
|
|||||||
status: String!
|
status: String!
|
||||||
message: 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)
|
config.Set(config.WallShowTitle, *input.WallShowTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.WallPlayback != nil {
|
||||||
|
config.Set(config.WallPlayback, *input.WallPlayback)
|
||||||
|
}
|
||||||
|
|
||||||
if input.MaximumLoopDuration != nil {
|
if input.MaximumLoopDuration != nil {
|
||||||
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
|
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) {
|
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
|
return "todo", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
|||||||
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||||
soundOnPreview := config.GetSoundOnPreview()
|
soundOnPreview := config.GetSoundOnPreview()
|
||||||
wallShowTitle := config.GetWallShowTitle()
|
wallShowTitle := config.GetWallShowTitle()
|
||||||
|
wallPlayback := config.GetWallPlayback()
|
||||||
maximumLoopDuration := config.GetMaximumLoopDuration()
|
maximumLoopDuration := config.GetMaximumLoopDuration()
|
||||||
autostartVideo := config.GetAutostartVideo()
|
autostartVideo := config.GetAutostartVideo()
|
||||||
showStudioAsText := config.GetShowStudioAsText()
|
showStudioAsText := config.GetShowStudioAsText()
|
||||||
@@ -75,6 +76,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
return &models.ConfigInterfaceResult{
|
return &models.ConfigInterfaceResult{
|
||||||
SoundOnPreview: &soundOnPreview,
|
SoundOnPreview: &soundOnPreview,
|
||||||
WallShowTitle: &wallShowTitle,
|
WallShowTitle: &wallShowTitle,
|
||||||
|
WallPlayback: &wallPlayback,
|
||||||
MaximumLoopDuration: &maximumLoopDuration,
|
MaximumLoopDuration: &maximumLoopDuration,
|
||||||
AutostartVideo: &autostartVideo,
|
AutostartVideo: &autostartVideo,
|
||||||
ShowStudioAsText: &showStudioAsText,
|
ShowStudioAsText: &showStudioAsText,
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ type ScenePreviewChunkOptions struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions) {
|
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string) {
|
||||||
args := []string{
|
args := []string{
|
||||||
"-v", "error",
|
"-v", "error",
|
||||||
|
"-xerror",
|
||||||
"-ss", strconv.Itoa(options.Time),
|
"-ss", strconv.Itoa(options.Time),
|
||||||
"-i", probeResult.Path,
|
"-i", probeResult.Path,
|
||||||
"-t", "0.75",
|
"-t", "0.75",
|
||||||
@@ -25,7 +26,7 @@ func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePre
|
|||||||
"-pix_fmt", "yuv420p",
|
"-pix_fmt", "yuv420p",
|
||||||
"-profile:v", "high",
|
"-profile:v", "high",
|
||||||
"-level", "4.2",
|
"-level", "4.2",
|
||||||
"-preset", "slow",
|
"-preset", preset,
|
||||||
"-crf", "21",
|
"-crf", "21",
|
||||||
"-threads", "4",
|
"-threads", "4",
|
||||||
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
|
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -264,7 +265,11 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
|||||||
result.Container = probeJSON.Format.FormatName
|
result.Container = probeJSON.Format.FormatName
|
||||||
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
||||||
result.Duration = math.Round(duration*100) / 100
|
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.Size = fileStat.Size()
|
||||||
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
|
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
|
||||||
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
|
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const MaximumLoopDuration = "maximum_loop_duration"
|
|||||||
const AutostartVideo = "autostart_video"
|
const AutostartVideo = "autostart_video"
|
||||||
const ShowStudioAsText = "show_studio_as_text"
|
const ShowStudioAsText = "show_studio_as_text"
|
||||||
const CSSEnabled = "cssEnabled"
|
const CSSEnabled = "cssEnabled"
|
||||||
|
const WallPlayback = "wall_playback"
|
||||||
|
|
||||||
// Playback force codec,container
|
// Playback force codec,container
|
||||||
const ForceMKV = "forceMKV"
|
const ForceMKV = "forceMKV"
|
||||||
@@ -241,6 +242,11 @@ func GetWallShowTitle() bool {
|
|||||||
return viper.GetBool(WallShowTitle)
|
return viper.GetBool(WallShowTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetWallPlayback() string {
|
||||||
|
viper.SetDefault(WallPlayback, "video")
|
||||||
|
return viper.GetString(WallPlayback)
|
||||||
|
}
|
||||||
|
|
||||||
func GetMaximumLoopDuration() int {
|
func GetMaximumLoopDuration() int {
|
||||||
viper.SetDefault(MaximumLoopDuration, 0)
|
viper.SetDefault(MaximumLoopDuration, 0)
|
||||||
return viper.GetInt(MaximumLoopDuration)
|
return viper.GetInt(MaximumLoopDuration)
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ type PreviewGenerator struct {
|
|||||||
VideoFilename string
|
VideoFilename string
|
||||||
ImageFilename string
|
ImageFilename string
|
||||||
OutputDirectory 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)
|
exists, err := utils.FileExists(videoFile.Path)
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -37,6 +42,9 @@ func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, image
|
|||||||
VideoFilename: videoFilename,
|
VideoFilename: videoFilename,
|
||||||
ImageFilename: imageFilename,
|
ImageFilename: imageFilename,
|
||||||
OutputDirectory: outputDirectory,
|
OutputDirectory: outputDirectory,
|
||||||
|
GenerateVideo: generateVideo,
|
||||||
|
GenerateImage: generateImage,
|
||||||
|
PreviewPreset: previewPreset,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +55,17 @@ func (g *PreviewGenerator) Generate() error {
|
|||||||
if err := g.generateConcatFile(); err != nil {
|
if err := g.generateConcatFile(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.GenerateVideo {
|
||||||
if err := g.generateVideo(&encoder); err != nil {
|
if err := g.generateVideo(&encoder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if g.GenerateImage {
|
||||||
if err := g.generateImage(&encoder); err != nil {
|
if err := g.generateImage(&encoder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +104,7 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error {
|
|||||||
Width: 640,
|
Width: 640,
|
||||||
OutputPath: chunkOutputPath,
|
OutputPath: chunkOutputPath,
|
||||||
}
|
}
|
||||||
encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options)
|
encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset)
|
||||||
}
|
}
|
||||||
|
|
||||||
videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename)
|
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 {
|
if s.Status.Status != Idle {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,6 +183,11 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
|
|||||||
//this.job.total = await ObjectionUtils.getCount(Scene);
|
//this.job.total = await ObjectionUtils.getCount(Scene);
|
||||||
instance.Paths.Generated.EnsureTmpDir()
|
instance.Paths.Generated.EnsureTmpDir()
|
||||||
|
|
||||||
|
preset := string(models.PreviewPresetSlow)
|
||||||
|
if previewPreset != nil && previewPreset.IsValid() {
|
||||||
|
preset = string(*previewPreset)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer s.returnToIdleState()
|
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")
|
logger.Info("Stopping due to user request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
totalsNeeded := s.neededGenerate(scenes, sprites, previews, markers, transcodes)
|
totalsNeeded := s.neededGenerate(scenes, sprites, previews, imagePreviews, markers, transcodes)
|
||||||
if totalsNeeded == nil {
|
if totalsNeeded == nil {
|
||||||
logger.Infof("Taking too long to count content. Skipping...")
|
logger.Infof("Taking too long to count content. Skipping...")
|
||||||
logger.Infof("Generating content")
|
logger.Infof("Generating content")
|
||||||
} else {
|
} 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 {
|
for i, scene := range scenes {
|
||||||
s.Status.setProgress(i, total)
|
s.Status.setProgress(i, total)
|
||||||
@@ -244,7 +249,7 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
|
|||||||
}
|
}
|
||||||
|
|
||||||
if previews {
|
if previews {
|
||||||
task := GeneratePreviewTask{Scene: *scene}
|
task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews, PreviewPreset: preset}
|
||||||
go task.Start(&wg)
|
go task.Start(&wg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,11 +609,12 @@ func (s *singleton) neededScan(paths []string) int64 {
|
|||||||
type totalsGenerate struct {
|
type totalsGenerate struct {
|
||||||
sprites int64
|
sprites int64
|
||||||
previews int64
|
previews int64
|
||||||
|
imagePreviews int64
|
||||||
markers int64
|
markers int64
|
||||||
transcodes 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
|
var totals totalsGenerate
|
||||||
const timeout = 90 * time.Second
|
const timeout = 90 * time.Second
|
||||||
@@ -633,10 +639,13 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if previews {
|
if previews {
|
||||||
task := GeneratePreviewTask{Scene: *scene}
|
task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews}
|
||||||
if !task.doesPreviewExist(task.Scene.Checksum) {
|
if !task.doesVideoPreviewExist(task.Scene.Checksum) {
|
||||||
totals.previews++
|
totals.previews++
|
||||||
}
|
}
|
||||||
|
if imagePreviews && !task.doesImagePreviewExist(task.Scene.Checksum) {
|
||||||
|
totals.imagePreviews++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if markers {
|
if markers {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
type GeneratePreviewTask struct {
|
type GeneratePreviewTask struct {
|
||||||
Scene models.Scene
|
Scene models.Scene
|
||||||
|
ImagePreview bool
|
||||||
|
PreviewPreset string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
||||||
@@ -17,7 +19,8 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
videoFilename := t.videoFilename()
|
videoFilename := t.videoFilename()
|
||||||
imageFilename := t.imageFilename()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
logger.Errorf("error creating preview generator: %s", err.Error())
|
logger.Errorf("error creating preview generator: %s", err.Error())
|
||||||
return
|
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))
|
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))
|
imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum))
|
||||||
return videoExists && imageExists
|
return imageExists
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *GeneratePreviewTask) videoFilename() string {
|
func (t *GeneratePreviewTask) videoFilename() string {
|
||||||
|
|||||||
@@ -28,11 +28,8 @@ func FileExists(path string) (bool, error) {
|
|||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, 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
|
// 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 { data: config, error, loading } = useConfiguration();
|
||||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
||||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
||||||
|
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
||||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||||
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
||||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
||||||
@@ -19,6 +20,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
const [updateInterfaceConfig] = useConfigureInterface({
|
const [updateInterfaceConfig] = useConfigureInterface({
|
||||||
soundOnPreview,
|
soundOnPreview,
|
||||||
wallShowTitle,
|
wallShowTitle,
|
||||||
|
wallPlayback,
|
||||||
maximumLoopDuration,
|
maximumLoopDuration,
|
||||||
autostartVideo,
|
autostartVideo,
|
||||||
showStudioAsText,
|
showStudioAsText,
|
||||||
@@ -31,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
const iCfg = config?.configuration?.interface;
|
const iCfg = config?.configuration?.interface;
|
||||||
setSoundOnPreview(iCfg?.soundOnPreview ?? true);
|
setSoundOnPreview(iCfg?.soundOnPreview ?? true);
|
||||||
setWallShowTitle(iCfg?.wallShowTitle ?? true);
|
setWallShowTitle(iCfg?.wallShowTitle ?? true);
|
||||||
|
setWallPlayback(iCfg?.wallPlayback ?? "video");
|
||||||
setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
|
setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
|
||||||
setAutostartVideo(iCfg?.autostartVideo ?? false);
|
setAutostartVideo(iCfg?.autostartVideo ?? false);
|
||||||
setShowStudioAsText(iCfg?.showStudioAsText ?? false);
|
setShowStudioAsText(iCfg?.showStudioAsText ?? false);
|
||||||
@@ -85,6 +88,22 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
label="Enable sound"
|
label="Enable sound"
|
||||||
onChange={() => setSoundOnPreview(!soundOnPreview)}
|
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">
|
<Form.Text className="text-muted">
|
||||||
Configuration for wall items
|
Configuration for wall items
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { mutateMetadataGenerate } from "src/core/StashService";
|
import { mutateMetadataGenerate } from "src/core/StashService";
|
||||||
|
import { PreviewPreset } from "src/core/generated-graphql";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
|
|
||||||
export const GenerateButton: React.FC = () => {
|
export const GenerateButton: React.FC = () => {
|
||||||
@@ -10,15 +11,21 @@ export const GenerateButton: React.FC = () => {
|
|||||||
const [markers, setMarkers] = useState(true);
|
const [markers, setMarkers] = useState(true);
|
||||||
const [transcodes, setTranscodes] = useState(false);
|
const [transcodes, setTranscodes] = useState(false);
|
||||||
const [thumbnails, setThumbnails] = useState(false);
|
const [thumbnails, setThumbnails] = useState(false);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState(false);
|
||||||
|
const [previewPreset, setPreviewPreset] = useState<string>(
|
||||||
|
PreviewPreset.Slow
|
||||||
|
);
|
||||||
|
|
||||||
async function onGenerate() {
|
async function onGenerate() {
|
||||||
try {
|
try {
|
||||||
await mutateMetadataGenerate({
|
await mutateMetadataGenerate({
|
||||||
sprites,
|
sprites,
|
||||||
previews,
|
previews,
|
||||||
|
imagePreviews: previews && imagePreviews,
|
||||||
markers,
|
markers,
|
||||||
transcodes,
|
transcodes,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
|
previewPreset: (previewPreset as PreviewPreset) ?? undefined,
|
||||||
});
|
});
|
||||||
Toast.success({ content: "Started generating" });
|
Toast.success({ content: "Started generating" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -29,18 +36,54 @@ export const GenerateButton: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Check
|
|
||||||
id="sprite-task"
|
|
||||||
checked={sprites}
|
|
||||||
label="Sprites (for the scene scrubber)"
|
|
||||||
onChange={() => setSprites(!sprites)}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
<Form.Check
|
||||||
id="preview-task"
|
id="preview-task"
|
||||||
checked={previews}
|
checked={previews}
|
||||||
label="Previews (video previews which play when hovering over a scene)"
|
label="Previews (video previews which play when hovering over a scene)"
|
||||||
onChange={() => setPreviews(!previews)}
|
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
|
<Form.Check
|
||||||
id="marker-task"
|
id="marker-task"
|
||||||
checked={markers}
|
checked={markers}
|
||||||
|
|||||||
@@ -1,64 +1,139 @@
|
|||||||
import _ from "lodash";
|
|
||||||
import React, { useRef, useState, useEffect } from "react";
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { useConfiguration } from "src/core/StashService";
|
||||||
import { useVideoHover } from "src/hooks";
|
|
||||||
import { TextUtils, NavUtils } from "src/utils";
|
import { TextUtils, NavUtils } from "src/utils";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
interface IWallItemProps {
|
interface IWallItemProps {
|
||||||
scene?: GQL.SlimSceneDataFragment;
|
scene?: GQL.SlimSceneDataFragment;
|
||||||
sceneMarker?: GQL.SceneMarkerDataFragment;
|
sceneMarker?: GQL.SceneMarkerDataFragment;
|
||||||
origin?: string;
|
|
||||||
onOverlay: (show: boolean) => void;
|
|
||||||
clickHandler?: (
|
clickHandler?: (
|
||||||
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
|
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
|
||||||
) => void;
|
) => 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) => {
|
export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
|
||||||
const [videoPath, setVideoPath] = useState<string>();
|
const [active, setActive] = useState(false);
|
||||||
const [previewPath, setPreviewPath] = useState<string>("");
|
const wallItem = useRef() as React.MutableRefObject<HTMLDivElement>;
|
||||||
const [screenshotPath, setScreenshotPath] = useState<string>("");
|
|
||||||
const [title, setTitle] = useState<string>("");
|
|
||||||
const [tags, setTags] = useState<JSX.Element[]>([]);
|
|
||||||
const config = useConfiguration();
|
const config = useConfiguration();
|
||||||
const hoverHandler = useVideoHover({
|
|
||||||
resetOnMouseLeave: true,
|
|
||||||
});
|
|
||||||
const showTextContainer =
|
const showTextContainer =
|
||||||
config.data?.configuration.interface.wallShowTitle ?? true;
|
config.data?.configuration.interface.wallShowTitle ?? true;
|
||||||
|
|
||||||
function onMouseEnter() {
|
const previews = props.sceneMarker
|
||||||
hoverHandler.onMouseEnter();
|
? {
|
||||||
if (!videoPath || videoPath === "") {
|
video: props.sceneMarker.stream,
|
||||||
|
animation: props.sceneMarker.preview,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
video: props.scene?.paths.preview ?? undefined,
|
||||||
|
animation: props.scene?.paths.webp ?? undefined,
|
||||||
|
image: props.scene?.paths.screenshot ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.sceneMarker) {
|
if (props.sceneMarker) {
|
||||||
setVideoPath(props.sceneMarker.stream || "");
|
props?.clickHandler?.(props.sceneMarker);
|
||||||
} else if (props.scene) {
|
|
||||||
setVideoPath(props.scene.paths.preview || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
props.onOverlay(true);
|
|
||||||
}
|
|
||||||
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
|
|
||||||
|
|
||||||
function onMouseLeave() {
|
|
||||||
hoverHandler.onMouseLeave();
|
|
||||||
setVideoPath("");
|
|
||||||
debouncedOnMouseEnter.current.cancel();
|
|
||||||
props.onOverlay(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick() {
|
|
||||||
if (props.clickHandler === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.scene !== undefined) {
|
|
||||||
props.clickHandler(props.scene);
|
|
||||||
} else if (props.sceneMarker !== undefined) {
|
|
||||||
props.clickHandler(props.sceneMarker);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let linkSrc: string = "#";
|
let linkSrc: string = "#";
|
||||||
if (!props.clickHandler) {
|
if (!props.clickHandler) {
|
||||||
@@ -69,89 +144,40 @@ export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
|
const renderText = () => {
|
||||||
const target = event.currentTarget;
|
if (!showTextContainer) return;
|
||||||
if (target.classList.contains("double-scale") && target.parentElement) {
|
|
||||||
target.parentElement.style.zIndex = "10";
|
|
||||||
} else if (target.parentElement) {
|
|
||||||
target.parentElement.style.zIndex = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const title = props.sceneMarker
|
||||||
if (props.sceneMarker) {
|
? `${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
|
||||||
setPreviewPath(props.sceneMarker.preview);
|
|
||||||
setTitle(
|
|
||||||
`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
|
|
||||||
props.sceneMarker.seconds
|
props.sceneMarker.seconds
|
||||||
)}`
|
)}`
|
||||||
);
|
: props.scene?.title ?? "";
|
||||||
const thisTags = props.sceneMarker.tags.map((tag) => (
|
const tags = props.sceneMarker
|
||||||
|
? [props.sceneMarker.primary_tag, ...props.sceneMarker.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wall-item-text">
|
||||||
|
<div>{title}</div>
|
||||||
|
{tags.map((tag) => (
|
||||||
<span key={tag.id} className="wall-tag">
|
<span key={tag.id} className="wall-tag">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
));
|
))}
|
||||||
thisTags.unshift(
|
</div>
|
||||||
<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]);
|
|
||||||
|
|
||||||
function previewNotFound() {
|
|
||||||
if (previewPath !== screenshotPath) {
|
|
||||||
setPreviewPath(screenshotPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="wall-item">
|
<div className="wall-item">
|
||||||
<div
|
<div className={`wall-item-container ${props.className}`} ref={wallItem}>
|
||||||
className={className.join(" ")}
|
<Link onClick={clickHandler} to={linkSrc} className="wall-item-anchor">
|
||||||
style={style}
|
<Preview
|
||||||
onTransitionEnd={onTransitionEnd}
|
previews={previews}
|
||||||
onMouseEnter={() => debouncedOnMouseEnter.current()}
|
config={config.data?.configuration}
|
||||||
onMouseMove={() => debouncedOnMouseEnter.current()}
|
active={active}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
<img
|
{renderText()}
|
||||||
alt={title}
|
|
||||||
className="scene-wall-image"
|
|
||||||
src={previewPath || screenshotPath}
|
|
||||||
onError={() => previewNotFound()}
|
|
||||||
/>
|
|
||||||
{showTextContainer ? (
|
|
||||||
<div className="scene-wall-item-text-container">
|
|
||||||
<div>{title}</div>
|
|
||||||
{tags}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { WallItem } from "./WallItem";
|
import { WallItem } from "./WallItem";
|
||||||
|
|
||||||
@@ -10,106 +10,58 @@ interface IWallPanelProps {
|
|||||||
) => void;
|
) => 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> = (
|
export const WallPanel: React.FC<IWallPanelProps> = (
|
||||||
props: IWallPanelProps
|
props: IWallPanelProps
|
||||||
) => {
|
) => {
|
||||||
const [showOverlay, setShowOverlay] = useState<boolean>(false);
|
const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => (
|
||||||
|
|
||||||
function onOverlay(show: boolean) {
|
|
||||||
setShowOverlay(show);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
<WallItem
|
||||||
key={scene.id}
|
key={scene.id}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
onOverlay={onOverlay}
|
|
||||||
clickHandler={props.clickHandler}
|
clickHandler={props.clickHandler}
|
||||||
origin={origin}
|
className={calculateClass(index, sceneArray.length)}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeRenderSceneMarkers() {
|
const sceneMarkers = (
|
||||||
if (props.sceneMarkers === undefined) {
|
props.sceneMarkers ?? []
|
||||||
return;
|
).map((marker, index, markerArray) => (
|
||||||
}
|
|
||||||
return props.sceneMarkers.map((marker, index) => {
|
|
||||||
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
|
|
||||||
return (
|
|
||||||
<WallItem
|
<WallItem
|
||||||
key={marker.id}
|
key={marker.id}
|
||||||
sceneMarker={marker}
|
sceneMarker={marker}
|
||||||
onOverlay={onOverlay}
|
|
||||||
clickHandler={props.clickHandler}
|
clickHandler={props.clickHandler}
|
||||||
origin={origin}
|
className={calculateClass(index, markerArray.length)}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const overlayClassName = showOverlay ? "visible" : "hidden";
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="row">
|
||||||
<div className={`wall-overlay ${overlayClassName}`} />
|
<div className="wall w-100 row justify-content-center">
|
||||||
<div className="wall row justify-content-center">
|
{scenes}
|
||||||
{maybeRenderScenes()}
|
{sceneMarkers}
|
||||||
{maybeRenderSceneMarkers()}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return render();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,69 +1,54 @@
|
|||||||
.wall-overlay {
|
.wall {
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
margin: 0 auto;
|
||||||
bottom: 0;
|
max-width: 2250px;
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
&-item {
|
||||||
position: fixed;
|
height: 11.25vw;
|
||||||
right: 0;
|
line-height: 0;
|
||||||
top: 0;
|
max-height: 253px;
|
||||||
transition: transform 0.5s ease-in-out;
|
max-width: 450px;
|
||||||
z-index: 1;
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
transition: z-index 0.5s 0.5s;
|
||||||
|
width: 20%;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
height: inherit;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visible {
|
&-anchor:hover {
|
||||||
opacity: 1;
|
text-decoration: none;
|
||||||
transition: opacity 0.5s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
&-media {
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visible-unanimated {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-unanimated {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.double-scale {
|
|
||||||
background-color: black;
|
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%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
transition: z-index 0s 0s;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-wall-item-container {
|
&-missing {
|
||||||
|
align-items: center;
|
||||||
|
color: $text-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
font-size: 1.5rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-height: 253px;
|
|
||||||
position: relative;
|
|
||||||
transition: transform 0.5s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-wall-item-container .scene-wall-video {
|
&-preview {
|
||||||
height: 100%;
|
left: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
top: 0;
|
||||||
|
transition: z-index 0s 0s;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-wall-item-text-container {
|
&-text {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
rgba(255, 255, 255, 0.25),
|
rgba(255, 255, 255, 0.25),
|
||||||
rgba(255, 255, 255, 0.65)
|
rgba(255, 255, 255, 0.65)
|
||||||
@@ -78,6 +63,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
z-index: 2000000;
|
||||||
|
|
||||||
.wall-tag {
|
.wall-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -87,25 +73,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-wall-item-blur {
|
&-container {
|
||||||
bottom: -5px;
|
background-color: black;
|
||||||
left: -5px;
|
display: flex;
|
||||||
position: absolute;
|
height: 100%;
|
||||||
right: -5px;
|
justify-content: center;
|
||||||
top: -5px;
|
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;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wall {
|
@media (min-width: 576px) {
|
||||||
.wall-item {
|
&:hover {
|
||||||
line-height: 0;
|
z-index: 2;
|
||||||
overflow: visible;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
width: 20%;
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
.wall-item-media {
|
||||||
width: 100%;
|
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