mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-12 20:22:03 -05:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65b5ce4301 | ||
|
|
3e7b553a5a | ||
|
|
a6064216a2 | ||
|
|
d42d185375 | ||
|
|
674df6bb22 | ||
|
|
e7c494e8e0 | ||
|
|
dabe7cd10e | ||
|
|
624064fa36 | ||
|
|
81069d276b | ||
|
|
1a515f1ed2 | ||
|
|
4bebf74f51 | ||
|
|
210bf9c162 | ||
|
|
f547d0de32 | ||
|
|
324a9c3802 | ||
|
|
972ba638df |
No files matched your search
+11
-12
@@ -5,6 +5,7 @@ go 1.27.1
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
@@ -21,21 +22,21 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/gtsteffaniak/go-media v1.2.1
|
||||
github.com/gtsteffaniak/go-media v1.2.2
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/image v0.48.0
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/sync v0.25.0
|
||||
golang.org/x/sys v0.50.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
golang.org/x/text v0.44.0
|
||||
golang.org/x/time v0.16.0
|
||||
golang.org/x/tools v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -44,21 +45,20 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.61.0 // indirect
|
||||
github.com/Eyevinn/mp4ff v0.62.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/abema/go-mp4 v1.7.4 // indirect
|
||||
github.com/at-wat/ebml-go v0.19.4 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.6 // indirect
|
||||
github.com/gen2brain/h265 v0.2.4 // indirect
|
||||
github.com/gen2brain/jxl v0.2.0 // indirect
|
||||
github.com/gen2brain/vpx v0.2.1 // indirect
|
||||
github.com/gen2brain/gav1d v0.2.7 // indirect
|
||||
github.com/gen2brain/h265 v0.2.5 // indirect
|
||||
github.com/gen2brain/jxl v0.2.1 // indirect
|
||||
github.com/gen2brain/vpx v0.2.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
@@ -81,7 +81,6 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0 // indirect
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
|
||||
+20
-24
@@ -2,8 +2,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Eyevinn/mp4ff v0.61.0 h1:uX3GBauEouxuGepRbv15JB7X2RyVyE2EZEfLpgimL6o=
|
||||
github.com/Eyevinn/mp4ff v0.61.0/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/Eyevinn/mp4ff v0.62.1 h1:EMuNIrmTCm5acOCVENca5Fzk+Mdek/l4bQZdk2EfReo=
|
||||
github.com/Eyevinn/mp4ff v0.62.1/go.mod h1:97zsDIJUiDTYqKpqq/b1K9foamxXmvsgE4GdPrPK3ks=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
@@ -64,14 +64,14 @@ github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDm
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
github.com/gen2brain/folio v0.2.8 h1:9iDI7h+zt4mjwEnzm1x8Pl9BrWqLwUwB74X/fnc1PZs=
|
||||
github.com/gen2brain/folio v0.2.8/go.mod h1:4/PcXfaXIYmW1nwQ4bJeE+aG5ybG58Aw9xrwODXaXII=
|
||||
github.com/gen2brain/gav1d v0.2.6 h1:7TSkrWTdE22k5Ul7vYZML7Nu80gNW6OovV7FSCITSE0=
|
||||
github.com/gen2brain/gav1d v0.2.6/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.4 h1:JUSbGkRAYZpd7ucCL192yx4ViEo75ZO6OEdm1TQt9qM=
|
||||
github.com/gen2brain/h265 v0.2.4/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.0 h1:cXSQnfbcC0JGFzaI344VzPMbe7mh6rYc0tjPcBUsE4U=
|
||||
github.com/gen2brain/jxl v0.2.0/go.mod h1:VjJHRai/8I8Es01mULqyiOihRBOjJr1Zo+NoN/MyIwo=
|
||||
github.com/gen2brain/vpx v0.2.1 h1:UD6tibcs/zlaIOKNFXeVyZuHBEaW9GYfxKSxvAN9cc8=
|
||||
github.com/gen2brain/vpx v0.2.1/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/gen2brain/gav1d v0.2.7 h1:ehu3jWt+0/46Is5llW/ZyX2OA3hTBJNukKnebzuk8KU=
|
||||
github.com/gen2brain/gav1d v0.2.7/go.mod h1:ReFKjHq7pvRhEk+pgRPAo9gOlcrf51sdzVcAbbX7e0E=
|
||||
github.com/gen2brain/h265 v0.2.5 h1:D3qCPx5GU+gHpinvLJtYMLSmQy64RGOm4fzMNGQcA1g=
|
||||
github.com/gen2brain/h265 v0.2.5/go.mod h1:kefpLxLhGpNCUG/q/aabJcD6GqB9Nhet3fNi9xX9Wzw=
|
||||
github.com/gen2brain/jxl v0.2.1 h1:74+8uw2RxUHGDVrDp+727PZ2hvAd5/psyV/mpsOQea0=
|
||||
github.com/gen2brain/jxl v0.2.1/go.mod h1:d7VMurg9ek62xfuyZyVAumFcuaQf9U8NlW57j/k5MQI=
|
||||
github.com/gen2brain/vpx v0.2.2 h1:kRCIx6Y2LsTe8hercAfhER2QbeHYnBOetovy4pkC4V0=
|
||||
github.com/gen2brain/vpx v0.2.2/go.mod h1:EVIdqguMSiapbtjDm4AWgFgYYJ4d+k8trXqjxYZ/4XE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -159,18 +159,14 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0 h1:CILp67bcOtiDXvnrQubnH9ctub0OSKnUNA2JJuoIP+c=
|
||||
github.com/gtsteffaniak/go-av1 v1.0.0/go.mod h1:C/dIrjfdUcHgdyChr2YXwLY3pIVjENI9npSd98PU4lE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmmq6v5tnA=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
|
||||
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
|
||||
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
|
||||
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
@@ -260,8 +256,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||
golang.org/x/image v0.48.0 h1:1AvuPcuoOTJpA7s4VZ5umrb90T3So0rkB/bSGmxkxik=
|
||||
golang.org/x/image v0.48.0/go.mod h1:mmtg64iqJKGqEgy+r13dfybl85tmxUd2lMnmaiSdWYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
@@ -281,8 +277,8 @@ golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -294,8 +290,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
@@ -305,8 +301,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -59,8 +59,8 @@ func runShadow[T any](op, path string, goLatency time.Duration, fast T, legacy L
|
||||
ffStart := time.Now()
|
||||
legacyVal, ffOK := legacy()
|
||||
ffLatency := time.Since(ffStart)
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(legacy-go)=%+.2fms legacy_ok=%t",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(ffLatency)-ms(goLatency), ffOK)
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(go-legacy)=%+.2fms",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(goLatency)-ms(ffLatency))
|
||||
|
||||
if !ffOK {
|
||||
// go-media read the file but the legacy path failed. That is an outcome
|
||||
|
||||
@@ -14,14 +14,21 @@ const (
|
||||
|
||||
var (
|
||||
mediaSettingsMu sync.RWMutex
|
||||
mediaSettingsDefault settings.MediaSettings
|
||||
mediaSettingsDefault settings.FullAdminMedia
|
||||
)
|
||||
|
||||
// saveMediaSettings is the only write path for media settings. It clears derived
|
||||
// fields (EnabledProfiles, ImagePreview) so read-time values are never persisted.
|
||||
func saveMediaSettings(key string, ms settings.FullAdminMedia) error {
|
||||
settings.ClearMediaDerived(&ms)
|
||||
return sqlDb.SaveSetting(key, ms)
|
||||
}
|
||||
|
||||
// InitMediaSettings loads persisted runtime media settings from SQLite and seeds from config when missing.
|
||||
func InitMediaSettings() error {
|
||||
if _, err := sqlDb.GetSetting(mediaSettingsDefaultKey); err != nil {
|
||||
seed := settings.SanitizeMediaSettings(settings.CurrentMediaSettings())
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
return fmt.Errorf("seed media settings: %w", saveErr)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +50,7 @@ func InitMediaSettings() error {
|
||||
// sanitize, so clamps (e.g. the readRate floor) must run again.
|
||||
overlaid = settings.SanitizeMediaSettings(overlaid)
|
||||
if !settings.MediaSettingsEqual(loaded, overlaid) {
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
return fmt.Errorf("save config-synced media settings: %w", saveErr)
|
||||
}
|
||||
loaded = overlaid
|
||||
@@ -57,22 +64,22 @@ func InitMediaSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMediaSettingsSetting(key string) (settings.MediaSettings, error) {
|
||||
func loadMediaSettingsSetting(key string) (settings.FullAdminMedia, error) {
|
||||
raw, err := sqlDb.GetSetting(key)
|
||||
if err != nil {
|
||||
return settings.MediaSettings{}, err
|
||||
return settings.FullAdminMedia{}, err
|
||||
}
|
||||
var ms settings.MediaSettings
|
||||
var ms settings.FullAdminMedia
|
||||
if err := json.Unmarshal(raw, &ms); err != nil {
|
||||
return settings.MediaSettings{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
return settings.FullAdminMedia{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// GetMediaSettings returns the runtime media settings. The value is deep-copied
|
||||
// GetMediaSettings returns the stored runtime media settings. The value is deep-copied
|
||||
// so callers cannot mutate the stored settings (or the live config they are
|
||||
// applied to) through the conversion maps.
|
||||
func GetMediaSettings() settings.MediaSettings {
|
||||
func GetMediaSettings() settings.FullAdminMedia {
|
||||
mediaSettingsMu.RLock()
|
||||
defer mediaSettingsMu.RUnlock()
|
||||
return settings.CloneMediaSettings(mediaSettingsDefault)
|
||||
@@ -90,8 +97,11 @@ func PatchMediaSettings(patchJSON []byte) error {
|
||||
return mergeErr
|
||||
}
|
||||
merged = settings.SanitizeMediaSettings(merged)
|
||||
// A patch may echo derived fields back (e.g. imagePreview from the admin UI).
|
||||
// Clear them before the value reaches memory or the database.
|
||||
settings.ClearMediaDerived(&merged)
|
||||
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
mediaSettingsMu.Unlock()
|
||||
return fmt.Errorf("save media settings: %w", saveErr)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -34,6 +35,56 @@ func TestGetMediaSettingsReturnsIndependentCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Derived read-time values must never reach the database, even when a caller
|
||||
// passes a struct that has them populated.
|
||||
func TestSaveMediaSettingsNeverPersistsDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
ms := GetMediaSettings()
|
||||
ms.Transcode.EnabledProfiles = []string{"quality"}
|
||||
ms.ImagePreview = map[settings.ImagePreviewType]bool{settings.JPEGImagePreview: true}
|
||||
if err := saveMediaSettings(mediaSettingsDefaultKey, ms); err != nil {
|
||||
t.Fatalf("saveMediaSettings: %v", err)
|
||||
}
|
||||
|
||||
raw, err := sqlDb.GetSetting(mediaSettingsDefaultKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting: %v", err)
|
||||
}
|
||||
var stored map[string]any
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("decode stored settings: %v", err)
|
||||
}
|
||||
transcode, ok := stored["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("stored transcode = %T, want object", stored["transcode"])
|
||||
}
|
||||
if _, ok := transcode["enabledProfiles"]; ok {
|
||||
t.Fatal("enabledProfiles (derived) was persisted")
|
||||
}
|
||||
if _, ok := stored["imagePreview"]; ok {
|
||||
t.Fatal("imagePreview (derived) was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// The admin UI echoes its whole payload back on PATCH, derived fields included.
|
||||
// Those must be dropped before the value reaches memory, not only before the database.
|
||||
func TestPatchMediaSettingsDropsEchoedDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
patch := []byte(`{"imagePreview":{"jpeg":true},"transcode":{"enabledProfiles":["quality"]}}`)
|
||||
if err := PatchMediaSettings(patch); err != nil {
|
||||
t.Fatalf("PatchMediaSettings: %v", err)
|
||||
}
|
||||
got := GetMediaSettings()
|
||||
if got.ImagePreview != nil {
|
||||
t.Fatal("echoed imagePreview stayed in memory")
|
||||
}
|
||||
if got.Transcode.EnabledProfiles != nil {
|
||||
t.Fatal("echoed enabledProfiles stayed in memory")
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh install seeds the stored media settings from config.
|
||||
func TestMediaSettingsSeedFromConfigOnFirstInit(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
@@ -19,11 +20,13 @@ func withMediaHandlerSettings(t *testing.T) {
|
||||
settings.Env.FFmpegAvailable = originalFFmpeg
|
||||
})
|
||||
settings.Env.FFmpegAvailable = true
|
||||
settings.Config.Integrations.Media.Transcode = settings.MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 3,
|
||||
MaxConcurrentPerUser: 2,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
settings.Config.Integrations.Media.Transcode = settings.FullAdminTranscode{
|
||||
UserTranscode: settings.UserTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrentPerUser: 2,
|
||||
},
|
||||
MaxConcurrent: 3,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,52 +55,99 @@ func callMediaGet(t *testing.T, admin bool) map[string]any {
|
||||
return body
|
||||
}
|
||||
|
||||
// Non-admin users must be able to read the effective media values for the
|
||||
// player, but only the player-relevant subset.
|
||||
// objectField returns obj[key] as a JSON object, failing the test if it is missing or not an object.
|
||||
func objectField(t *testing.T, obj map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
child, ok := obj[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s is %T, want object", key, obj[key])
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
// keysOf returns the sorted keys of a JSON object.
|
||||
func keysOf(obj map[string]any) []string {
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, path string, got []string, want []string) {
|
||||
t.Helper()
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin users receive exactly the PUBLIC payload. Any new key fails this test,
|
||||
// which forces a conscious decision about whether it belongs in UserMedia.
|
||||
func TestSettingsMediaHandlerPlayerPayloadForNonAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
body := callMediaGet(t, false)
|
||||
if _, ok := body["transcode"]; ok {
|
||||
t.Fatal("non-admin must not receive the full transcode settings")
|
||||
assertKeys(t, "top-level", keysOf(body), []string{"transcode", "imagePreview"})
|
||||
transcode := objectField(t, body, "transcode")
|
||||
assertKeys(t, "transcode", keysOf(transcode), []string{"enabled", "maxConcurrentPerUser", "enabledProfiles"})
|
||||
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want true", transcode["enabled"])
|
||||
}
|
||||
if _, ok := body["lockedFromConfigPaths"]; ok {
|
||||
t.Fatal("non-admin must not receive config lock metadata")
|
||||
if limit, ok := transcode["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("transcode.maxConcurrentPerUser = %v, want 2", transcode["maxConcurrentPerUser"])
|
||||
}
|
||||
if _, ok := body["lockMessage"]; ok {
|
||||
t.Fatal("non-admin must not receive the config lock message")
|
||||
}
|
||||
if enabled, ok := body["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("enabled = %v, want true", body["enabled"])
|
||||
}
|
||||
if limit, ok := body["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("maxConcurrentPerUser = %v, want 2", body["maxConcurrentPerUser"])
|
||||
}
|
||||
profiles, ok := body["profiles"].([]any)
|
||||
profiles, ok := transcode["enabledProfiles"].([]any)
|
||||
if !ok || len(profiles) != 3 {
|
||||
t.Fatalf("profiles = %v, want 3 enabled ids", body["profiles"])
|
||||
t.Fatalf("transcode.enabledProfiles = %v, want 3 enabled ids", transcode["enabledProfiles"])
|
||||
}
|
||||
}
|
||||
|
||||
// Admins receive the full stored config. The keys shared with users must have the
|
||||
// same names, so the admin payload is a strict superset of the user payload.
|
||||
func TestSettingsMediaHandlerFullPayloadForAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
body := callMediaGet(t, true)
|
||||
transcode, ok := body["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("admin must receive the full transcode settings, got %T", body["transcode"])
|
||||
}
|
||||
if _, ok := transcode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode payload must include profiles, got %v", transcode["profiles"])
|
||||
}
|
||||
// Admin payload exposes the full settings surface, not the player subset.
|
||||
for _, key := range []string{"convert", "debug", "hardwareAcceleration"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
admin := callMediaGet(t, true)
|
||||
user := callMediaGet(t, false)
|
||||
|
||||
// lockedFromConfigPaths is omitted when config pins nothing, so it is not required here.
|
||||
for _, key := range []string{"transcode", "imagePreview", "convert", "debug", "hardwareAcceleration", "accelDevice"} {
|
||||
if _, ok := admin[key]; !ok {
|
||||
t.Fatalf("admin payload missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := body["enabled"]; ok {
|
||||
t.Fatal("the admin payload uses the full settings shape, not the player subset")
|
||||
if _, ok := admin["enabled"]; ok {
|
||||
t.Fatal("admin payload must not repeat transcode.enabled at the top level")
|
||||
}
|
||||
|
||||
adminTranscode := objectField(t, admin, "transcode")
|
||||
for _, key := range []string{"maxConcurrent", "profiles", "cacheMaxSizeMB", "encoderBackend", "readRate"} {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := adminTranscode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode.profiles must be an object, got %T", adminTranscode["profiles"])
|
||||
}
|
||||
|
||||
// Superset rule: every key a user receives must also exist for admins. Values may
|
||||
// differ by design: admins see stored config (so the UI can save it back unchanged),
|
||||
// users see effective values (for example enabled is false when ffmpeg is missing).
|
||||
for key := range objectField(t, user, "transcode") {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing shared key %q", key)
|
||||
}
|
||||
}
|
||||
if admin["imagePreview"] == nil {
|
||||
t.Fatal("imagePreview must be present for admins")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +156,8 @@ func TestSettingsMediaHandlerReflectsDisabledTranscode(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
settings.Config.Integrations.Media.Transcode.Enabled = false
|
||||
|
||||
body := callMediaGet(t, false)
|
||||
if enabled, ok := body["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("enabled = %v, want false", body["enabled"])
|
||||
transcode := objectField(t, callMediaGet(t, false), "transcode")
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want false", transcode["enabled"])
|
||||
}
|
||||
}
|
||||
@@ -453,33 +453,17 @@ func settingsShareDefaultsPatchHandler(w http.ResponseWriter, r *http.Request, d
|
||||
return settingsShareDefaultsGetHandler(w, r, d)
|
||||
}
|
||||
|
||||
// settingsMediaGetHandler returns the runtime media settings.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Admins receive the DB-backed media integration settings; every authenticated user
|
||||
// @Description receives the player-relevant subset (transcode enabled, profiles, per-user limit).
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
// mediaSettingsResponse is the runtime media settings plus config-lock metadata.
|
||||
// mediaSettingsResponse is the ADMIN ONLY media payload: the full stored settings
|
||||
// (FullAdminMedia, which embeds the public UserTranscode fields) plus config-lock
|
||||
// metadata and ffmpeg option lists. Non-admins never receive this type.
|
||||
type mediaSettingsResponse struct {
|
||||
settings.MediaSettings
|
||||
settings.FullAdminMedia
|
||||
LockedFromConfigPaths []string `json:"lockedFromConfigPaths,omitempty"`
|
||||
LockMessage string `json:"lockMessage,omitempty"`
|
||||
AccelDeviceOptions []ffmpeg.AccelDeviceOption `json:"accelDeviceOptions,omitempty"`
|
||||
EncoderBackendOptions []ffmpeg.EncoderBackendOption `json:"encoderBackendOptions,omitempty"`
|
||||
}
|
||||
|
||||
// mediaPlayerSettings is the player-relevant subset of the runtime media
|
||||
// settings. It is safe to expose to any authenticated user so the player can
|
||||
// reflect admin changes without a page reload.
|
||||
type mediaPlayerSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Profiles []string `json:"profiles"`
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"`
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"`
|
||||
}
|
||||
|
||||
func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
paths = settings.ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
@@ -491,12 +475,25 @@ func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
// The media settings APIs no longer require ffmpeg. go-media serves probing,
|
||||
// metadata, subtitles, and video thumbnails without it. ffmpeg-only controls
|
||||
// (transcoding, hardware acceleration) report their own availability.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Returns the media settings the caller is allowed to see.
|
||||
// @Description Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
// @Description Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
// @Description transcodes they can run at once, which quality presets are enabled, and which image
|
||||
// @Description previews the server can generate. Keys in the view are the same for admins and users.
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
// PUBLIC payload: non-admins get only what the player and general UI read.
|
||||
if !d.User.Permissions.Admin {
|
||||
return RenderJSON(w, r, currentMediaPlayerSettings())
|
||||
return RenderJSON(w, r, settings.CurrentUserMedia())
|
||||
}
|
||||
// ADMIN payload: full stored settings with effective and derived values filled in
|
||||
// by PrepMediaForFrontend (a read-only copy; never saved).
|
||||
media := settings.PrepMediaForFrontend(state.GetMediaSettings())
|
||||
lockedPaths, lockMessage := mediaSettingsLockMeta()
|
||||
media := state.GetMediaSettings()
|
||||
svc := ffmpeg.Get()
|
||||
var accelDeviceOptions []ffmpeg.AccelDeviceOption
|
||||
var encoderBackendOptions []ffmpeg.EncoderBackendOption
|
||||
@@ -506,7 +503,7 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
encoderBackendOptions = svc.EncoderBackendOptions()
|
||||
}
|
||||
return RenderJSON(w, r, mediaSettingsResponse{
|
||||
MediaSettings: media,
|
||||
FullAdminMedia: media,
|
||||
LockedFromConfigPaths: lockedPaths,
|
||||
LockMessage: lockMessage,
|
||||
AccelDeviceOptions: accelDeviceOptions,
|
||||
@@ -514,17 +511,6 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
})
|
||||
}
|
||||
|
||||
// currentMediaPlayerSettings reads the live media settings for the player UI.
|
||||
func currentMediaPlayerSettings() mediaPlayerSettings {
|
||||
media := state.GetMediaSettings()
|
||||
return mediaPlayerSettings{
|
||||
Enabled: settings.TranscodeEnabled(),
|
||||
Profiles: settings.EnabledTranscodeProfiles(),
|
||||
MaxConcurrentPerUser: settings.TranscodeMaxConcurrentPerUser(),
|
||||
HardwareAcceleration: media.HardwareAcceleration,
|
||||
}
|
||||
}
|
||||
|
||||
// settingsMediaPatchHandler merges a partial update into the runtime media settings.
|
||||
// @Summary Update runtime media settings
|
||||
// @Description Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.
|
||||
|
||||
@@ -151,7 +151,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
externalLinks = newExternalLinks
|
||||
}
|
||||
|
||||
defaultThemeColor := settings.DefaultBackgroundColor()
|
||||
// Instance default dark mode: htmlVars/defaultDark use a boolean for first paint;
|
||||
// globalVars.darkMode carries the full preference (including "auto") for the SPA.
|
||||
defaultDark := settings.DefaultDarkMode()
|
||||
defaultThemeColor := settings.Config.Frontend.Styling.LightBackground
|
||||
if defaultDark {
|
||||
defaultThemeColor = settings.Config.Frontend.Styling.DarkBackground
|
||||
}
|
||||
staticURL := settings.Config.Http.BaseURL + "public/static"
|
||||
description := settings.Config.Frontend.Description
|
||||
title := settings.Config.Frontend.Name
|
||||
@@ -276,57 +282,51 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// only include constant values that don't change during a users session
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"hardwareAccelerationAvailable": settings.HardwareAccelerationSupported(),
|
||||
"transcodeEnabled": settings.TranscodeEnabled(),
|
||||
"transcodeProfiles": settings.EnabledTranscodeProfiles(),
|
||||
"transcodeMaxPerUser": settings.TranscodeMaxConcurrentPerUser(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"enableJxlConversion": settings.CanConvertImage("jxl"),
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.DefaultDarkModePreference(),
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
}
|
||||
|
||||
cspNonce, err := utils.CSPNonce()
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestTranscodeManagerIsWiredWithoutFFmpeg(t *testing.T) {
|
||||
// Startup with ffmpeg missing and transcoding disabled.
|
||||
settings.Env.FFmpegAvailable = false
|
||||
disabled := originalMedia
|
||||
disabled.Transcode = settings.MediaTranscode{Enabled: false}
|
||||
disabled.Transcode = settings.FullAdminTranscode{UserTranscode: settings.UserTranscode{Enabled: false}}
|
||||
settings.ApplyMediaSettings(disabled)
|
||||
|
||||
setupTestEnv(t)
|
||||
|
||||
@@ -428,7 +428,7 @@ const (
|
||||
// clampTranscodeReadRate enforces throttled producers: unset or sub-realtime
|
||||
// read rates are raised, so a producer is never left unthrottled. Burst and
|
||||
// catch-up pacing are left to ffmpeg's defaults.
|
||||
func clampTranscodeReadRate(t *MediaTranscode) {
|
||||
func clampTranscodeReadRate(t *FullAdminTranscode) {
|
||||
if t.ReadRate <= 0 {
|
||||
t.ReadRate = defaultTranscodeReadRate
|
||||
} else if t.ReadRate < minTranscodeReadRate {
|
||||
@@ -1075,14 +1075,16 @@ func SetDefaults(generate bool) Settings {
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
s.Integrations.Media.Transcode = MediaTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
s.Integrations.Media.Transcode = FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrentPerUser: 1,
|
||||
},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
}
|
||||
// Hardware acceleration is opt-out: "auto" lets go-ffmpeg follow its device
|
||||
// hierarchy (integrated GPU, discrete GPU, then software).
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false,
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
|
||||
@@ -68,14 +68,14 @@ func MediaPatchJSONForPaths(source any, paths []string) ([]byte, error) {
|
||||
|
||||
// ApplyConfigSpecifiedPathsToMedia overlays config media values onto stored settings for the
|
||||
// paths the config file pins, so a config value always wins over the stored one.
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored MediaSettings, config Media) (MediaSettings, error) {
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored FullAdminMedia, config Media) (FullAdminMedia, error) {
|
||||
paths := ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
return stored, nil
|
||||
}
|
||||
patchJSON, err := MediaPatchJSONForPaths(config, paths)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
if len(patchJSON) == 0 || string(patchJSON) == "{}" || string(patchJSON) == "null" {
|
||||
return stored, nil
|
||||
|
||||
@@ -60,11 +60,11 @@ func TestValidateMediaPatchNotConfigLockedWithoutLocks(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
withConfigMediaPaths(t, "hardwareAcceleration", "transcode.profiles.datasaver.avgBitrateKbps")
|
||||
|
||||
stored := MediaSettings{
|
||||
stored := FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true},
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 1500},
|
||||
},
|
||||
@@ -72,7 +72,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
}
|
||||
config := Media{
|
||||
HardwareAcceleration: false,
|
||||
Transcode: MediaTranscode{
|
||||
Transcode: FullAdminTranscode{
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 800},
|
||||
},
|
||||
@@ -97,7 +97,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMediaNoLocks(t *testing.T) {
|
||||
withConfigMediaPaths(t)
|
||||
|
||||
stored := MediaSettings{HardwareAcceleration: true}
|
||||
stored := FullAdminMedia{HardwareAcceleration: true}
|
||||
overlaid, err := ApplyConfigSpecifiedPathsToMedia(stored, Media{HardwareAcceleration: false})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyConfigSpecifiedPathsToMedia: %v", err)
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
// MergeMediaSettingsPatchJSON merges a partial JSON patch into base media settings.
|
||||
func MergeMediaSettingsPatchJSON(base MediaSettings, patchJSON []byte) (MediaSettings, error) {
|
||||
func MergeMediaSettingsPatchJSON(base FullAdminMedia, patchJSON []byte) (FullAdminMedia, error) {
|
||||
baseBytes, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
mergedBytes, err := mergeUserDefaultsJSON(baseBytes, patchJSON)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
var merged MediaSettings
|
||||
var merged FullAdminMedia
|
||||
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
|
||||
return MediaSettings{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
return FullAdminMedia{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
}
|
||||
// An explicit accelDevice wins over the legacy hardwareAcceleration bool in
|
||||
// the base settings, which would otherwise force the device back to software.
|
||||
@@ -35,7 +35,7 @@ func MergeMediaSettingsPatchJSON(base MediaSettings, patchJSON []byte) (MediaSet
|
||||
}
|
||||
|
||||
// MediaSettingsEqual reports whether two runtime media settings are equivalent.
|
||||
func MediaSettingsEqual(a, b MediaSettings) bool {
|
||||
func MediaSettingsEqual(a, b FullAdminMedia) bool {
|
||||
ab, errA := json.Marshal(a)
|
||||
bb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
@@ -46,7 +46,7 @@ func MediaSettingsEqual(a, b MediaSettings) bool {
|
||||
|
||||
// ApplyMediaSettingsIfChanged applies runtime media settings
|
||||
// only when they actually differ. It never re-runs ffmpeg detection.
|
||||
func ApplyMediaSettingsIfChanged(s MediaSettings) {
|
||||
func ApplyMediaSettingsIfChanged(s FullAdminMedia) {
|
||||
if MediaSettingsEqual(CurrentMediaSettings(), s) {
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func ApplyMediaSettingsIfChanged(s MediaSettings) {
|
||||
|
||||
// CloneMediaSettings returns a deep copy of m — the conversion maps hold *bool
|
||||
// values, so a shallow copy would alias the live config's reference state.
|
||||
func CloneMediaSettings(m MediaSettings) MediaSettings {
|
||||
func CloneMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
out := m
|
||||
out.Convert = FfmpegConvert{
|
||||
ImagePreview: cloneBoolMap(m.Convert.ImagePreview),
|
||||
@@ -84,7 +84,7 @@ func cloneBoolPtr(v *bool) *bool {
|
||||
}
|
||||
|
||||
// SanitizeMediaSettings clamps runtime media settings into supported ranges.
|
||||
func SanitizeMediaSettings(m MediaSettings) MediaSettings {
|
||||
func SanitizeMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
t := &m.Transcode
|
||||
if t.MaxConcurrent < 1 {
|
||||
t.MaxConcurrent = 2
|
||||
|
||||
@@ -2,17 +2,16 @@ package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func testMediaSettings() MediaSettings {
|
||||
return MediaSettings{
|
||||
func testMediaSettings() FullAdminMedia {
|
||||
return FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 1},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -54,11 +53,11 @@ func TestMergeMediaSettingsPatchJSONProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeMediaSettings(t *testing.T) {
|
||||
in := MediaSettings{
|
||||
Transcode: MediaTranscode{
|
||||
MaxConcurrent: -1,
|
||||
MaxConcurrentPerUser: 0,
|
||||
CacheRetentionH: 0,
|
||||
in := FullAdminMedia{
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{MaxConcurrentPerUser: 0},
|
||||
MaxConcurrent: -1,
|
||||
CacheRetentionH: 0,
|
||||
Profiles: TranscodeProfiles{
|
||||
Quality: TranscodeProfile{MaxResolution: -5, AvgBitrateKbps: -1},
|
||||
},
|
||||
@@ -116,10 +115,10 @@ func TestSanitizeMediaSettingsAccelDevice(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := SanitizeMediaSettings(MediaSettings{
|
||||
out := SanitizeMediaSettings(FullAdminMedia{
|
||||
AccelDevice: tc.device,
|
||||
HardwareAcceleration: tc.hwAccel,
|
||||
Transcode: MediaTranscode{MaxConcurrent: 1, MaxConcurrentPerUser: 1, CacheRetentionH: 1},
|
||||
Transcode: FullAdminTranscode{UserTranscode: UserTranscode{MaxConcurrentPerUser: 1}, MaxConcurrent: 1, CacheRetentionH: 1},
|
||||
})
|
||||
if out.AccelDevice != tc.wantDevice || out.HardwareAcceleration != tc.wantHW {
|
||||
t.Fatalf("accelDevice = %q/%v, want %q/%v", out.AccelDevice, out.HardwareAcceleration, tc.wantDevice, tc.wantHW)
|
||||
|
||||
@@ -209,8 +209,10 @@ func TranscodeCacheRetention() time.Duration {
|
||||
return time.Duration(h) * time.Hour
|
||||
}
|
||||
|
||||
// CurrentMediaSettings returns the runtime media settings (excluding ffmpegPath).
|
||||
func CurrentMediaSettings() MediaSettings {
|
||||
// CurrentMediaSettings returns the stored runtime media settings (excluding ffmpegPath).
|
||||
// Derived fields (ImagePreview, EnabledProfiles) are not populated; use CurrentFullAdminMedia
|
||||
// or CurrentUserMedia for response payloads.
|
||||
func CurrentMediaSettings() FullAdminMedia {
|
||||
mediaMu.RLock()
|
||||
defer mediaMu.RUnlock()
|
||||
return currentMediaSettingsLocked()
|
||||
@@ -218,24 +220,68 @@ func CurrentMediaSettings() MediaSettings {
|
||||
|
||||
// currentMediaSettingsLocked snapshots the live media settings. Callers must hold
|
||||
// mediaMu. The returned value is deep-copied so it cannot alias the live config.
|
||||
func currentMediaSettingsLocked() MediaSettings {
|
||||
func currentMediaSettingsLocked() FullAdminMedia {
|
||||
m := Config.Integrations.Media
|
||||
return CloneMediaSettings(MediaSettings{
|
||||
return CloneMediaSettings(FullAdminMedia{
|
||||
Convert: m.Convert,
|
||||
Debug: m.Debug,
|
||||
HardwareAcceleration: m.HardwareAcceleration,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
})
|
||||
}
|
||||
|
||||
// ImagePreviewAvailability resolves each supported image preview type to whether
|
||||
// conversion is active right now, using the same check the preview pipeline uses.
|
||||
func ImagePreviewAvailability() map[ImagePreviewType]bool {
|
||||
out := make(map[ImagePreviewType]bool, len(AllImagePreviewTypes))
|
||||
for _, t := range AllImagePreviewTypes {
|
||||
out[t] = CanConvertImage(string(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentUserMedia builds the PUBLIC media payload for non-admin users. Values come
|
||||
// from the same accessors as the admin payload, so the shared fields always agree.
|
||||
// The getters take mediaMu themselves, so they are called here without holding it.
|
||||
func CurrentUserMedia() UserMedia {
|
||||
return UserMedia{
|
||||
Transcode: UserTranscode{
|
||||
Enabled: TranscodeEnabled(),
|
||||
MaxConcurrentPerUser: TranscodeMaxConcurrentPerUser(),
|
||||
EnabledProfiles: EnabledTranscodeProfiles(),
|
||||
},
|
||||
ImagePreview: ImagePreviewAvailability(),
|
||||
}
|
||||
}
|
||||
|
||||
// PrepMediaForFrontend returns a read-only copy of m with derived values filled in,
|
||||
// following PrepForFrontend in internal/web/users.go. The caller's value is not modified.
|
||||
// Stored config (transcode.enabled, maxConcurrentPerUser) is deliberately NOT replaced
|
||||
// with effective values: the admin UI edits these fields and sends them back, so they
|
||||
// must stay the stored values. Only derived fields are filled.
|
||||
// NEVER pass the result to SaveSetting without ClearMediaDerived first.
|
||||
func PrepMediaForFrontend(m FullAdminMedia) FullAdminMedia {
|
||||
m.Transcode.EnabledProfiles = EnabledTranscodeProfiles()
|
||||
m.ImagePreview = ImagePreviewAvailability()
|
||||
return m
|
||||
}
|
||||
|
||||
// ClearMediaDerived zeroes the fields that PrepMediaForFrontend derives at read time so
|
||||
// they are never written to the database. Enabled and MaxConcurrentPerUser are stored
|
||||
// config and are not cleared here.
|
||||
func ClearMediaDerived(m *FullAdminMedia) {
|
||||
m.Transcode.EnabledProfiles = nil
|
||||
m.ImagePreview = nil
|
||||
}
|
||||
|
||||
// ApplyMediaSettings applies runtime media settings to the in-memory config.
|
||||
// It never re-runs ffmpeg detection: codec/hardware detection happens exactly
|
||||
// once at startup (InitFFmpeg). hardwareAcceleration is read live per transcode;
|
||||
// ffmpegPath, accelDevice, debug and concurrency apply on the next restart.
|
||||
func ApplyMediaSettings(s MediaSettings) {
|
||||
func ApplyMediaSettings(s FullAdminMedia) {
|
||||
mediaMu.Lock()
|
||||
defer mediaMu.Unlock()
|
||||
Config.Integrations.Media.Convert = s.Convert
|
||||
|
||||
@@ -25,16 +25,15 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
originalTranscode := Config.Integrations.Media.Transcode
|
||||
t.Cleanup(func() { Config.Integrations.Media.Transcode = originalTranscode })
|
||||
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 4,
|
||||
MaxConcurrentPerUser: 2,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 2},
|
||||
MaxConcurrent: 4,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,7 +66,7 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
|
||||
func TestTranscodeAccessorsUseDefaultsWhenUnset(t *testing.T) {
|
||||
withMediaConfig(t)
|
||||
ApplyMediaSettings(MediaSettings{})
|
||||
ApplyMediaSettings(FullAdminMedia{})
|
||||
|
||||
if got := TranscodeMaxConcurrent(); got != 2 {
|
||||
t.Fatalf("TranscodeMaxConcurrent = %d, want the 2 default", got)
|
||||
@@ -225,11 +224,11 @@ func TestMediaAccessorsAreRaceFree(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
enabled = !enabled
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
Debug: enabled,
|
||||
HardwareAcceleration: enabled,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: enabled,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: enabled},
|
||||
MaxConcurrent: 2,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSyncEnforcedSourcePermissionsOntoUser_skipsAdmin(t *testing.T) {
|
||||
enforced := SourceFilePermissionsEnforcement{Create: true}
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{
|
||||
@@ -90,4 +90,3 @@ func TestValidateSelfUserUpdateScopesNotEnforced_caseInsensitive(t *testing.T) {
|
||||
t.Fatal("expected mixed-case scope field to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,43 +175,63 @@ type Media struct {
|
||||
// The fields below are deprecated and migrated to runtime (DB-backed) media settings on
|
||||
// first run. They are kept only to seed the database from an existing config.yaml.
|
||||
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode MediaTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode FullAdminTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
|
||||
// AccelDevice selects the acceleration device: "auto" (default), "software"/"cpu",
|
||||
// "igpu", "dgpu", a Linux DRM render node (e.g. /dev/dri/renderD128), or a GPU
|
||||
// name substring. Managed via the admin media settings API.
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
}
|
||||
|
||||
// MediaSettings holds the runtime (DB-backed) media integration settings.
|
||||
// FfmpegPath is config-only and intentionally excluded.
|
||||
type MediaSettings struct {
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode MediaTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
// FullAdminMedia holds every runtime (DB-backed) media integration setting.
|
||||
// ADMIN ONLY: non-admins receive UserMedia instead. FfmpegPath is config-only and intentionally excluded.
|
||||
// ImagePreview is derived at read time (see PrepMediaForFrontend) and is cleared before save.
|
||||
type FullAdminMedia struct {
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview,omitempty"` // image formats that can be converted
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode FullAdminTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
}
|
||||
|
||||
// MediaTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
type MediaTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
// UserTranscode is PUBLIC: transcode fields every authenticated user receives in
|
||||
// the UserMedia payload. FullAdminTranscode embeds it, so the same JSON keys appear
|
||||
// in both admin and user responses. Add a field here only if the UI reads it.
|
||||
type UserTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
EnabledProfiles []string `json:"enabledProfiles,omitempty"` // derived at read time (player-menu profile ids); never stored
|
||||
}
|
||||
|
||||
// UserMedia is PUBLIC: the only media payload non-admin users receive.
|
||||
// Add a top-level key here only if the player or general UI reads it.
|
||||
type UserMedia struct {
|
||||
Transcode UserTranscode `json:"transcode"`
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // availability, derived at read time
|
||||
}
|
||||
|
||||
// FullAdminTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
// ADMIN ONLY beyond the embedded UserTranscode fields.
|
||||
type FullAdminTranscode struct {
|
||||
// inline: flattens the public fields in YAML (config.yaml) the same way the embed
|
||||
// flattens them in JSON, so config keys stay transcode.enabled, not transcode.UserTranscode.enabled.
|
||||
UserTranscode `yaml:",inline"`
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
}
|
||||
|
||||
// TranscodeProfiles configures the player's transcode quality presets.
|
||||
|
||||
@@ -134,6 +134,12 @@ func DefaultDarkModeIsAuto() bool {
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeAuto
|
||||
}
|
||||
|
||||
// DefaultDarkModePreference is the configured instance default for globalVars.darkMode
|
||||
// ("auto", "dark", or "light"). Unlike DefaultDarkMode, it preserves "auto" for the SPA.
|
||||
func DefaultDarkModePreference() users.DarkMode {
|
||||
return Config.UserDefaults.UI.DarkMode.Or(users.DarkModeDark)
|
||||
}
|
||||
|
||||
// DefaultBackgroundColor returns the background color of the instance's default theme.
|
||||
func DefaultBackgroundColor() string {
|
||||
if DefaultDarkMode() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestDefaultDarkModePreferencePreservesAuto(t *testing.T) {
|
||||
original := Config.UserDefaults.UI.DarkMode
|
||||
t.Cleanup(func() { Config.UserDefaults.UI.DarkMode = original })
|
||||
|
||||
Config.UserDefaults.UI.DarkMode = users.DarkModeAuto
|
||||
if got := DefaultDarkModePreference(); got != users.DarkModeAuto {
|
||||
t.Fatalf("DefaultDarkModePreference() = %q, want auto", got)
|
||||
}
|
||||
if DefaultDarkMode() {
|
||||
t.Fatal("DefaultDarkMode() should be false when preference is auto")
|
||||
}
|
||||
if !DefaultDarkModeIsAuto() {
|
||||
t.Fatal("DefaultDarkModeIsAuto() should be true when preference is auto")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(DefaultDarkModePreference())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `"auto"` {
|
||||
t.Fatalf("json.Marshal preference = %s, want \"auto\"", raw)
|
||||
}
|
||||
}
|
||||
@@ -3217,6 +3217,24 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6299,6 +6317,58 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6640,57 +6710,12 @@ const docTemplate = `{
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8963,7 +8988,6 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -9006,6 +9030,13 @@ const docTemplate = `{
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9019,7 +9050,7 @@ const docTemplate = `{
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3206,6 +3206,24 @@
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6288,6 +6306,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6629,57 +6699,12 @@
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8952,7 +8977,6 @@
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -8995,6 +9019,13 @@
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9008,7 +9039,7 @@
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -480,6 +480,46 @@ definitions:
|
||||
styling:
|
||||
$ref: '#/definitions/settings.StylingConfig'
|
||||
type: object
|
||||
settings.FullAdminTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
enabledProfiles:
|
||||
description: derived at read time (player-menu profile ids); never stored
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.Http:
|
||||
properties:
|
||||
baseURL:
|
||||
@@ -764,45 +804,10 @@ definitions:
|
||||
type: boolean
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
description: 'deprecated: HLS transcoding for media playback. deprecated:
|
||||
manage via the admin media settings API'
|
||||
type: object
|
||||
settings.MediaTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.OidcConfig:
|
||||
properties:
|
||||
adminGroup:
|
||||
@@ -2423,9 +2428,6 @@ definitions:
|
||||
description: File metadata for non-text files
|
||||
type: object
|
||||
web.mediaSettingsResponse:
|
||||
description: Admins receive the DB-backed media integration settings; every authenticated
|
||||
user receives the player-relevant subset (transcode enabled, profiles, per-user
|
||||
limit).
|
||||
properties:
|
||||
accelDevice:
|
||||
description: 'acceleration device: "auto", "software"/"cpu", "igpu", "dgpu",
|
||||
@@ -2458,6 +2460,11 @@ definitions:
|
||||
hardwareAcceleration:
|
||||
description: enable hardware acceleration for ffmpeg if available
|
||||
type: boolean
|
||||
imagePreview:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: image formats that can be converted
|
||||
type: object
|
||||
lockMessage:
|
||||
type: string
|
||||
lockedFromConfigPaths:
|
||||
@@ -2466,7 +2473,7 @@ definitions:
|
||||
type: array
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
description: HLS transcoding for media playback
|
||||
type: object
|
||||
web.pinnedItemPatchRequest:
|
||||
@@ -4767,6 +4774,23 @@ paths:
|
||||
tags:
|
||||
- Settings
|
||||
/api/settings/media:
|
||||
get:
|
||||
description: |-
|
||||
Returns the media settings the caller is allowed to see.
|
||||
Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
transcodes they can run at once, which quality presets are enabled, and which image
|
||||
previews the server can generate. Keys in the view are the same for admins and users.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Media settings
|
||||
schema:
|
||||
$ref: '#/definitions/web.mediaSettingsResponse'
|
||||
summary: Get runtime media settings
|
||||
tags:
|
||||
- Settings
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
|
||||
@@ -1085,6 +1085,9 @@ export async function checksum(source, path, algo) {
|
||||
/**
|
||||
* URL to open a file inline in a new browser tab via the download endpoint.
|
||||
* Uses inline disposition and respects download permissions and share limits.
|
||||
* @param {string} source
|
||||
* @param {string} path
|
||||
* @param {{ path?: string, hash?: string } | null} [shareInfo]
|
||||
*/
|
||||
export function getOpenFileURL(source, path, shareInfo = null) {
|
||||
if (isMediaFile(path)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { normalizeMediaPlayerSettings } from "@/utils/mediaSettings.js";
|
||||
import { normalizeSharedMediaSettings } from "@/utils/mediaSettings.js";
|
||||
import { fetchJSON, fetchURL, requestTimeoutSignal } from "./utils.ts";
|
||||
|
||||
const analyticsRequestTimeoutMs = 5000;
|
||||
@@ -124,13 +124,14 @@ export function getMediaSettings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective media settings for playback. Every authenticated user may read this;
|
||||
* admins receive the full settings object and everyone else the player subset.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, hardwareAcceleration: boolean }>}
|
||||
* Shared runtime media state (player + general UI), normalized to a flat shape.
|
||||
* Non-admins receive the PUBLIC UserMedia payload; admins get the full payload on the
|
||||
* same endpoint, which carries the same transcode and imagePreview keys.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, imagePreview: object }>}
|
||||
*/
|
||||
export async function getMediaPlayerSettings() {
|
||||
export async function getSharedMediaSettings() {
|
||||
const data = await fetchJSON(getApiPath("settings/media"));
|
||||
return normalizeMediaPlayerSettings(data);
|
||||
return normalizeSharedMediaSettings(data);
|
||||
}
|
||||
|
||||
export async function patchMediaSettings(partial) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="icon === 'close_back'"
|
||||
type="button"
|
||||
:disabled="isDisabled"
|
||||
@click="actionMultiIcon"
|
||||
@click="onClick"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
@@ -31,7 +31,7 @@
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
@click="action"
|
||||
@click="onClick"
|
||||
>
|
||||
<i :class="iconClass">{{ icon }}</i>
|
||||
<span>{{ label }}</span>
|
||||
@@ -39,58 +39,59 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store";
|
||||
<script lang="ts">
|
||||
const OUTLINED_ICONS = new Set([
|
||||
"table_rows_narrow",
|
||||
"content_copy",
|
||||
"file_copy",
|
||||
"copy_all",
|
||||
"insert_drive_file",
|
||||
]);
|
||||
|
||||
export default {
|
||||
name: "action",
|
||||
props: ["icon", "label", "counter", "show", "isDisabled", "customIconClass"],
|
||||
computed: {
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
multistate() {
|
||||
return getters.multibuttonState();
|
||||
},
|
||||
stickSidebar() {
|
||||
return state.user?.stickySidebar;
|
||||
},
|
||||
currentView() {
|
||||
return getters.currentView();
|
||||
},
|
||||
iconClass() {
|
||||
if (this.customIconClass) {
|
||||
return this.customIconClass;
|
||||
}
|
||||
const outlinedIcons = [
|
||||
'table_rows_narrow',
|
||||
'content_copy',
|
||||
'file_copy',
|
||||
'copy_all',
|
||||
'insert_drive_file'
|
||||
];
|
||||
return outlinedIcons.includes(this.icon)
|
||||
? 'material-symbols-outlined'
|
||||
: 'material-symbols';
|
||||
},
|
||||
export function actionIconClass(icon: string, customIconClass?: string): string {
|
||||
if (customIconClass) return customIconClass;
|
||||
return OUTLINED_ICONS.has(icon) ? "material-symbols-outlined" : "material-symbols";
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getters, mutations } from "@/store";
|
||||
import type { Prompt } from "@/store/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
label?: string;
|
||||
counter?: number;
|
||||
show?: Prompt | string | null;
|
||||
isDisabled?: boolean;
|
||||
customIconClass?: string;
|
||||
}>(),
|
||||
{
|
||||
icon: "",
|
||||
label: "",
|
||||
counter: 0,
|
||||
show: null,
|
||||
isDisabled: false,
|
||||
customIconClass: "",
|
||||
},
|
||||
methods: {
|
||||
actionMultiIcon() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
action() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
},
|
||||
};
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
|
||||
const multistate = computed(() => getters.multibuttonState());
|
||||
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
|
||||
|
||||
function onClick() {
|
||||
if (props.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(props.show);
|
||||
}
|
||||
emit("action");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: { share: false, creatable: [], user: { scopes: [{ name: "data" }, { name: "inbox" }] } },
|
||||
}));
|
||||
vi.mock("@/store", () => ({
|
||||
state: { get user() { return store.user; } },
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source) => store.creatable.includes(source),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const showCopy = (permissions) =>
|
||||
ContextMenu.computed.showCopy.call({ showLimitedOptions: false, showCreate: false, selectedCount: 1, permissions });
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(showCopy({ download: true, modify: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(showCopy({ download: false, modify: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
expect(showCopy({ download: true, modify: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, type App } from "vue";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: {
|
||||
share: false,
|
||||
creatable: [] as string[],
|
||||
scopes: [{ name: "data" }, { name: "inbox" }],
|
||||
permissions: { view: true, download: true, modify: false, create: false, delete: false },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
state: {
|
||||
selected: [],
|
||||
multiple: false,
|
||||
isSearchActive: false,
|
||||
shareInfo: {},
|
||||
navigation: {},
|
||||
sources: { current: "data" },
|
||||
req: { items: [], source: "data", path: "/" },
|
||||
user: {
|
||||
get scopes() {
|
||||
return store.scopes;
|
||||
},
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setContextMenuHasItems: vi.fn(),
|
||||
closeHovers: vi.fn(),
|
||||
closeTopPrompt: vi.fn(),
|
||||
showPrompt: vi.fn(),
|
||||
},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source: string) => store.creatable.includes(source),
|
||||
globalPermissions: () => ({}),
|
||||
sourcePermissions: () => store.permissions,
|
||||
currentPromptName: () => "ContextMenu",
|
||||
currentPrompt: () => ({ props: {} }),
|
||||
currentTool: () => null,
|
||||
currentView: () => "listingView",
|
||||
isMobile: () => false,
|
||||
isAdmin: () => false,
|
||||
isLoggedIn: () => true,
|
||||
isPreviewView: () => false,
|
||||
isSplitViewActive: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/url.js", () => ({ goToItem: vi.fn(), getParentDir: vi.fn(), removeLastDir: vi.fn() }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: () => false, nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({
|
||||
isRichTextPreviewMimeType: () => false,
|
||||
getTypeInfoFromExt: () => ({ materialSymbol: "", classes: "" }),
|
||||
}));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: () => false }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
let app: App | null = null;
|
||||
let host: HTMLElement | null = null;
|
||||
|
||||
/** Mounts the menu on one selected item and returns the icons of the actions it offers. */
|
||||
function offeredIcons(permissions: Partial<typeof store.permissions>): string[] {
|
||||
store.permissions = { view: true, download: false, modify: false, create: false, delete: false, ...permissions };
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
app = createApp({
|
||||
render: () => h(ContextMenu, { items: [{ name: "a.txt", path: "/a.txt", source: "data" }] }),
|
||||
});
|
||||
app.mount(host);
|
||||
return [...host.querySelectorAll("#context-menu .action > i")].map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
app = null;
|
||||
host = null;
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(offeredIcons({ download: true, modify: false })).toContain("file_copy");
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(offeredIcons({ download: true, modify: false })).not.toContain("file_copy");
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(offeredIcons({ download: false, modify: true })).not.toContain("file_copy");
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(offeredIcons({ download: true, modify: false })).not.toContain("file_copy");
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
expect(offeredIcons({ download: true, modify: true })).toContain("file_copy");
|
||||
});
|
||||
});
|
||||
+801
-1030
File diff suppressed because it is too large.
Load diff
@@ -731,7 +731,7 @@ function selectResultAt(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
#results {
|
||||
animation: slide-down 0.5s forwards;
|
||||
animation: slide-down 0.22s cubic-bezier(0.2, 0, 0, 1) forwards;
|
||||
border-radius: var(--borderRadius);
|
||||
border-top: none;
|
||||
border-top-left-radius: 0;
|
||||
@@ -785,7 +785,7 @@ function selectResultAt(target: EventTarget | null): boolean {
|
||||
right: calc(2.5% + var(--safe-area-right));
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
animation: expand 0.3s ease 0.1s backwards;
|
||||
animation: expand 0.22s cubic-bezier(0.2, 0, 0, 1) backwards;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
@@ -1019,7 +1019,7 @@ body.rtl #search #result ul>* {
|
||||
animation: none;
|
||||
}
|
||||
.search-input-container {
|
||||
transition: height 1s ease, background-color 1s ease, box-shadow 1s ease;
|
||||
transition: height 0.22s cubic-bezier(0.2, 0, 0, 1), background-color 0.22s ease, box-shadow 0.22s ease;
|
||||
}
|
||||
#search.active .search-input-container {
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 10%);
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
moveWithSidebar() {
|
||||
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
|
||||
return {
|
||||
left: `${state.sidebar.width}em`,
|
||||
left: "var(--sidebar-width)",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: 0;
|
||||
transition: left 0.2s ease;
|
||||
transition: left 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
moveWithSidebar() {
|
||||
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
|
||||
return {
|
||||
left: `${state.sidebar.width}em`,
|
||||
left: "var(--sidebar-width)",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
@@ -247,7 +247,7 @@ export default {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
|
||||
transition: bottom 0.5s ease, left 0.22s cubic-bezier(0.2, 0, 0, 1), width 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<i class="material-symbols">{{ btn.icon }}</i>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<transition name="expand" @before-enter="expandBeforeEnter" @enter="expandEnter" @leave="expandLeave">
|
||||
<transition :css="false" @enter="popEnter" @leave="popLeave">
|
||||
<ul v-if="openMenu === btn.menu" :ref="(el) => setIconMenuEl(btn.menu as 'align' | 'clipboard', el as HTMLElement | null)" class="editor-toolbar-menu editor-toolbar-menu--icon-menu floating-window border-radius" :style="menuStyle">
|
||||
<li v-for="item in iconMenuItems(btn.menu)" :key="item.id">
|
||||
<button
|
||||
@@ -112,7 +112,7 @@
|
||||
<i class="material-symbols">more_horiz</i>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<transition name="expand" @before-enter="expandBeforeEnter" @enter="expandEnter" @leave="expandLeave">
|
||||
<transition :css="false" @enter="popEnter" @leave="popLeave">
|
||||
<ul v-if="openMenu === 'extra'" ref="extraMenu" class="editor-toolbar-menu floating-window border-radius" :style="menuStyle">
|
||||
<li v-for="item in extraMenuItems" :key="item.id">
|
||||
<button
|
||||
@@ -141,7 +141,7 @@ import type { Ace } from "ace-builds";
|
||||
import { mutations, state, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { removeLastDir } from "@/utils/url.js";
|
||||
import { expandBeforeEnter, expandEnter, expandLeave } from "@/utils/expandTransition";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
@@ -329,7 +329,7 @@ const extraMenuItems = computed((): ToolbarButton[] => {
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
if (openMenu.value === "align" || openMenu.value === "clipboard") {
|
||||
return { top: `${menuPosition.value.top}px`, left: `${menuPosition.value.left}px`, transform: "translateX(-50%)" };
|
||||
return { top: `${menuPosition.value.top}px`, left: `${menuPosition.value.left}px`, translate: "-50% 0" };
|
||||
}
|
||||
return { top: `${menuPosition.value.top}px`, right: `${menuPosition.value.right}px` };
|
||||
});
|
||||
@@ -984,9 +984,11 @@ defineExpose({ save });
|
||||
padding-bottom: 0.65em;
|
||||
list-style: none;
|
||||
z-index: 9999;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.editor-toolbar-menu--icon-menu {
|
||||
transform-origin: top center;
|
||||
display: flex;
|
||||
gap: 0.15em;
|
||||
padding-bottom: 0.5em;
|
||||
@@ -1004,9 +1006,4 @@ defineExpose({ save });
|
||||
.editor-toolbar-menu-btn .material-symbols {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -40,9 +40,9 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from '@/store';
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import throttle from "@/utils/throttle";
|
||||
|
||||
@@ -122,7 +122,40 @@ export default {
|
||||
isLoaded() {
|
||||
return !("preview-img" in (state?.loading || {}));
|
||||
},
|
||||
// Which cached entry to look, or null when no thumbnail should be shown
|
||||
isHeicOrHeif() {
|
||||
const type = state.req?.type;
|
||||
return type === "image/heic" || type === "image/heif";
|
||||
},
|
||||
/** Full-resolution source for the viewer (Safari HEIC/HEIF uses the native file, not size=original). */
|
||||
fullImageSrc() {
|
||||
if (!this.isHeicOrHeif || !state.isSafari || !state.req) {
|
||||
return this.src;
|
||||
}
|
||||
const viewToken = state.req.viewToken;
|
||||
const typeHint = state.req.type || state.req.name;
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
}
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
null,
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
},
|
||||
// Cached preview thumbs (from listing's preview API) shown while the full image loads.
|
||||
thumbnailLookup() {
|
||||
if (!state?.req) {
|
||||
return null;
|
||||
@@ -130,11 +163,9 @@ export default {
|
||||
if (!this.path || !state.req.hasPreview) {
|
||||
return null;
|
||||
}
|
||||
// Don't use thumbnail when we show original embedded preview (HEIC/HEIF or raw)
|
||||
const isHeicOrHeif = state.req?.type === "image/heic" || state.req?.type === "image/heif";
|
||||
const useOriginalForHeic = isHeicOrHeif && (state.isSafari || (globalVars.ffmpegAvailable && globalVars.enableHeicConversion));
|
||||
const useOriginalForRaw = isRawImageMimeType(state.req?.type);
|
||||
if (useOriginalForHeic || useOriginalForRaw) {
|
||||
const skipHeicThumbnail = this.isHeicOrHeif && !state.isSafari && state.req.hasPreview;
|
||||
const skipRawThumbnail = isRawImageMimeType(state.req?.type);
|
||||
if (skipHeicThumbnail || skipRawThumbnail) {
|
||||
return null;
|
||||
}
|
||||
// For shares, use shareInfo.hash as the source; otherwise use this.source
|
||||
@@ -299,7 +330,8 @@ export default {
|
||||
}
|
||||
},
|
||||
async loadFullImage() {
|
||||
if (!this.src) return;
|
||||
const fullSrc = this.fullImageSrc;
|
||||
if (!fullSrc) return;
|
||||
mutations.setLoading("preview-img", true);
|
||||
|
||||
// Set src directly via JavaScript to avoid Vue's HTML entity encoding in template bindings
|
||||
@@ -307,7 +339,7 @@ export default {
|
||||
await this.$nextTick();
|
||||
if (this.$refs.imgex && 'src' in this.$refs.imgex) {
|
||||
// Decode any HTML entities (Vue shouldn't encode props, but decode just in case)
|
||||
const cleanSrc = String(this.src).replace(/&/g, '&');
|
||||
const cleanSrc = String(fullSrc).replace(/&/g, '&');
|
||||
this.$refs.imgex.src = cleanSrc;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
dragging: dragState.type === 'previous',
|
||||
active: (dragState.atFullExtent && dragState.type === 'previous') || (gestureHint === 'previous' && gestureHintCommitReady),
|
||||
'media-mode': isMediaQueueMode,
|
||||
'sidebar-resizing': isSidebarResizing,
|
||||
'nav-button--hidden': !showNav,
|
||||
}"
|
||||
:style="previousButtonStyle"
|
||||
@@ -87,7 +86,6 @@
|
||||
active: gestureHint === 'close' && gestureHintCommitReady,
|
||||
'media-mode': isMediaQueueMode,
|
||||
'gesture-flash': gestureHintFlashClose,
|
||||
'sidebar-resizing': isSidebarResizing,
|
||||
'nav-button--hidden': !showCloseNavChrome,
|
||||
}"
|
||||
:style="closeButtonStyle"
|
||||
@@ -146,7 +144,6 @@ export default {
|
||||
tapTimeout: null,
|
||||
triggered: false
|
||||
},
|
||||
isSidebarResizing: false, // Track if sidebar is being resized
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -199,8 +196,7 @@ export default {
|
||||
closeButtonStyle() {
|
||||
const styles = { right: 'auto' };
|
||||
if (this.moveWithSidebar) {
|
||||
const half = this.sidebarWidth / 2;
|
||||
styles.left = `calc(50% + ${half}em)`;
|
||||
styles.left = 'calc(50% + var(--sidebar-width) / 2)';
|
||||
} else {
|
||||
styles.left = '50%';
|
||||
}
|
||||
@@ -246,7 +242,7 @@ export default {
|
||||
width: '5em',
|
||||
};
|
||||
if (this.moveWithSidebar) {
|
||||
styles.left = `${this.sidebarWidth}em`;
|
||||
styles.left = 'var(--sidebar-width)';
|
||||
} else {
|
||||
styles.left = '0';
|
||||
}
|
||||
@@ -259,7 +255,7 @@ export default {
|
||||
}
|
||||
// Calculate left position based on sidebar
|
||||
if (this.moveWithSidebar) {
|
||||
styles.left = `calc(${this.sidebarWidth}em + 1em)`; // When sidebar is sticky the position of the button will have a tiny padding
|
||||
styles.left = 'calc(var(--sidebar-width) + 1em)'; // tiny padding next to the sticky sidebar
|
||||
} else {
|
||||
styles.left = '1em';
|
||||
}
|
||||
@@ -325,10 +321,6 @@ export default {
|
||||
'state.navigation.nextLink'() {
|
||||
this.showInitialNavigation();
|
||||
},
|
||||
'state.sidebar.isResizing'(newVal) {
|
||||
// Track when sidebar is being resized to disable transitions
|
||||
this.isSidebarResizing = newVal;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
window.addEventListener("keydown", this.keyEvent);
|
||||
@@ -1072,11 +1064,6 @@ export default {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Disable transitions during sidebar resizing */
|
||||
.nav-button.sidebar-resizing {
|
||||
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-button.nav-close {
|
||||
/* Below header; +4em vs previous placement; horizontal position from closeButtonStyle when sidebar offsets main */
|
||||
top: calc(1.25em + 4em);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
if (!skipped) {
|
||||
this.media = media;
|
||||
}
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
void mutations.syncMediaSettings();
|
||||
notify.showSuccess(this.$t("general.saved"));
|
||||
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
|
||||
this.close();
|
||||
|
||||
@@ -238,7 +238,7 @@ export default {
|
||||
if (!skipped) {
|
||||
this.media = media;
|
||||
}
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
void mutations.syncMediaSettings();
|
||||
notify.showSuccess(this.$t("general.saved"));
|
||||
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
|
||||
this.close();
|
||||
|
||||
@@ -16,7 +16,7 @@ const { store, router, notify } = vi.hoisted(() => {
|
||||
setCurrentUser: vi.fn((user) => { state.user = user; }),
|
||||
setSession: vi.fn(),
|
||||
syncEnforcedUserDefaults: vi.fn(),
|
||||
syncMediaPlayerSettings: vi.fn(),
|
||||
syncMediaSettings: vi.fn(),
|
||||
syncSidebarLinkDefaultsPolicy: vi.fn(),
|
||||
syncToolAccessDefaultsPolicy: vi.fn(),
|
||||
syncShareDefaultsPolicy: vi.fn(),
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<transition
|
||||
name="expand"
|
||||
@before-enter="beforeEnter"
|
||||
:css="false"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
@after-leave="onPanelAfterLeave"
|
||||
@@ -109,8 +108,7 @@
|
||||
</div>
|
||||
<transition
|
||||
v-else
|
||||
name="expand"
|
||||
@before-enter="beforeEnter"
|
||||
:css="false"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
@after-leave="onPanelAfterLeave"
|
||||
@@ -146,15 +144,12 @@
|
||||
|
||||
<script>
|
||||
import MenuOptionList from "@/components/MenuOptionList.vue";
|
||||
import {
|
||||
expandBeforeEnter,
|
||||
expandEnter,
|
||||
expandLeave,
|
||||
} from "@/utils/expandTransition";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
|
||||
let expandDropdownIdCounter = 0;
|
||||
const EXPAND_OPEN_MS = 300;
|
||||
const EXPAND_CLOSE_MS = 150;
|
||||
const EXPAND_OPEN_MS = 200;
|
||||
const EXPAND_CLOSE_MS = 120;
|
||||
const PANEL_FROM_SCALE_X = 1;
|
||||
|
||||
export default {
|
||||
name: "ExpandDropdown",
|
||||
@@ -534,13 +529,13 @@ export default {
|
||||
if (this.allowSearch) {
|
||||
this.$refs.menuOptions?.focusSearch();
|
||||
}
|
||||
expandEnter(el, () => {
|
||||
popEnter(el, () => {
|
||||
this.updateOverlayPosition();
|
||||
done();
|
||||
}, EXPAND_OPEN_MS);
|
||||
}, EXPAND_OPEN_MS, PANEL_FROM_SCALE_X);
|
||||
},
|
||||
leave(el, done) {
|
||||
expandLeave(el, done, EXPAND_CLOSE_MS);
|
||||
popLeave(el, done, EXPAND_CLOSE_MS, PANEL_FROM_SCALE_X);
|
||||
},
|
||||
observePanelResize() {
|
||||
const panel = this.$refs.panel;
|
||||
@@ -626,7 +621,6 @@ export default {
|
||||
this.$emit("update:modelValue", option.value);
|
||||
this.close();
|
||||
},
|
||||
beforeEnter: expandBeforeEnter,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -770,6 +764,7 @@ export default {
|
||||
}
|
||||
|
||||
.expand-dropdown-body:not(.expand-upward) {
|
||||
transform-origin: top center;
|
||||
margin-top: -2px;
|
||||
border-width: 0 var(--borderWidth) var(--borderWidth);
|
||||
border-top-left-radius: 0 !important;
|
||||
@@ -777,6 +772,7 @@ export default {
|
||||
}
|
||||
|
||||
.expand-dropdown-body.expand-upward {
|
||||
transform-origin: bottom center;
|
||||
margin-bottom: -2px;
|
||||
border-width: var(--borderWidth) var(--borderWidth) 0;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
@@ -831,20 +827,4 @@ export default {
|
||||
.expand-dropdown--transparent:not(.expand-dropdown--open) .expand-dropdown-chevron {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.expand-enter-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-leave-active {
|
||||
transition: height 0.15s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import { state, getters } from "@/store";
|
||||
import { getters } from "@/store/index.js";
|
||||
|
||||
const buttonGroup = new Map<string, { visible: boolean; zoneActive: boolean; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
|
||||
@@ -211,7 +211,7 @@ export default {
|
||||
const isLeftPositioned = this.position === "top-left" || this.position === "bottom-left";
|
||||
const pushedBySidebar = isLeftPositioned && getters.isSidebarVisible() && getters.isStickySidebar();
|
||||
if (pushedBySidebar && style.left === undefined) {
|
||||
style.left = `calc(var(--fab-edge-offset-media) + ${state.sidebar.width}em)`;
|
||||
style.left = `calc(var(--fab-edge-offset-media) + var(--sidebar-width))`;
|
||||
}
|
||||
|
||||
return style;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<transition name="expand" @before-enter="beforeEnter" @enter="enter" @leave="leave">
|
||||
<transition :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<div v-show="!actuallyCollapsed" class="settings-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<script>
|
||||
import { SETTINGS_ACCORDION_KEY } from "./settingsAccordion.js";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions.ts";
|
||||
|
||||
export default {
|
||||
name: 'SettingsItem',
|
||||
@@ -89,45 +90,8 @@ export default {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.$emit('toggle', !this.isCollapsed);
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
*/
|
||||
beforeEnter(el) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
enter(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = '';
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
void element.offsetHeight;
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
element.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
element.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
leave(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
void element.offsetHeight;
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -165,27 +129,9 @@ export default {
|
||||
padding: 0 0.5em 1em;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-group-title.unclickable {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Prevent content height issues during animation */
|
||||
.settings-content .input,
|
||||
.settings-content .settings-items {
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -77,7 +77,7 @@
|
||||
step="1"
|
||||
:value="account.maxConcurrentTranscodes"
|
||||
@change="emitAccountChange('maxConcurrentTranscodes', clampMaxConcurrentTranscodes($event.target.value))"
|
||||
:disabled="isFieldDisabled('maxConcurrentTranscodes')"
|
||||
:disabled="isFieldDisabled('maxConcurrentTranscodes') || !maxConcurrentTranscodesLimitKnown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +138,6 @@ import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import { getObjectProperty } from "@/utils/object.js";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { state } from "@/store";
|
||||
|
||||
let transcodeInputCounter = 0;
|
||||
@@ -192,14 +191,15 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
maxConcurrentTranscodesLimitKnown() {
|
||||
return this.maxConcurrentTranscodesLimit !== undefined;
|
||||
},
|
||||
maxConcurrentTranscodesLimit() {
|
||||
// Prefer the live media settings; globalVars are static page constants.
|
||||
const live = state.mediaPlayerSettings?.maxConcurrentPerUser;
|
||||
const live = state.mediaSettings?.maxConcurrentPerUser;
|
||||
if (Number.isFinite(live) && live > 0) {
|
||||
return live;
|
||||
}
|
||||
const limit = globalVars?.transcodeMaxPerUser;
|
||||
return Number.isFinite(limit) && limit > 0 ? limit : undefined;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
|
||||
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
|
||||
vi.mock("@/store", () => ({ state: {}, getters: {}, mutations: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
import General from "./General.vue";
|
||||
|
||||
describe("sidebar File actions leave transition", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("finishes even when no transitionend ever fires", () => {
|
||||
vi.useFakeTimers();
|
||||
const el = document.createElement("div");
|
||||
const done = vi.fn();
|
||||
General.methods.leave(el, done);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -50,8 +50,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Sidebar file actions -->
|
||||
<transition v-if="shareInfo.shareType !== 'upload'" name="expand" @before-enter="beforeEnter" @enter="enter"
|
||||
@leave="leave">
|
||||
<transition v-if="shareInfo.shareType !== 'upload'" :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<div v-if="showFileActions" class="card-wrapper">
|
||||
<button type="button" @click="openContextMenu" aria-label="File-Actions" data-testid="file-actions-button" class="action file-actions">
|
||||
<i class="material-symbols">add</i>
|
||||
@@ -82,7 +81,7 @@ import {
|
||||
import SidebarLinks from "./Links.vue";
|
||||
import { url } from "@/utils";
|
||||
import { normalizeDarkMode } from "@/utils/theme.js";
|
||||
import { expandLeave } from "@/utils/expandTransition";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions";
|
||||
|
||||
export default {
|
||||
name: "SidebarGeneral",
|
||||
@@ -230,29 +229,8 @@ export default {
|
||||
}
|
||||
await logout(sharePath);
|
||||
},
|
||||
beforeEnter(el) {
|
||||
el.style.maxHeight = '0';
|
||||
el.style.opacity = '0';
|
||||
},
|
||||
enter(el, done) {
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'max-height 0.2s ease, opacity 0.15s ease';
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
const onTransitionEnd = () => {
|
||||
el.style.maxHeight = '';
|
||||
el.removeEventListener('transitionend', onTransitionEnd);
|
||||
done();
|
||||
};
|
||||
el.addEventListener('transitionend', onTransitionEnd);
|
||||
});
|
||||
},
|
||||
leave(el, done) {
|
||||
// Finish on a timer, not transitionend: a card shown on first render never ran enter() and has no
|
||||
// transition, and sidebar resizing sets `transition: none`. Either way transitionend never fires,
|
||||
// done() is never called, and the card stays mounted, invisible but still clickable.
|
||||
expandLeave(el, done, 200);
|
||||
},
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
showHoverTooltip(text, event);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div v-if="isShare && !disableShareCard" class="share-info-section">
|
||||
<ShareInfo :hash="hash" :token="token" :sub-path="subPath" />
|
||||
</div>
|
||||
<transition-group name="expand" tag="div" class="inner-card">
|
||||
<transition-group :css="false" tag="div" class="inner-card" @enter="slideEnter" @leave="slideLeave">
|
||||
<template v-for="(link, index) in sidebarLinksToDisplay" :key="`link-${index}-${link.category}`">
|
||||
<!-- Divider: renders as hr line or text label -->
|
||||
<div v-if="link.category === 'divider'" class="sidebar-divider-container">
|
||||
@@ -178,6 +178,7 @@ import { showShareDownloadPrompt } from "@/utils/download.js";
|
||||
import ShareInfo from "@/components/files/ShareInfo.vue";
|
||||
import FileTree from '@/components/files/FileTree.vue';
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { slideEnter, slideLeave } from "@/utils/transitions";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
export default {
|
||||
name: "SidebarLinks",
|
||||
@@ -297,6 +298,8 @@ export default {
|
||||
hideInteractiveTooltip(true);
|
||||
},
|
||||
methods: {
|
||||
slideEnter,
|
||||
slideLeave,
|
||||
isSourceCategory(category) {
|
||||
return isSourceSidebarCategory(category);
|
||||
},
|
||||
@@ -531,32 +534,6 @@ export default {
|
||||
name: "SidebarLinks",
|
||||
});
|
||||
},
|
||||
beforeEnter(el) {
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
},
|
||||
enter(el, done) {
|
||||
el.style.transition = '';
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
// Force reflow
|
||||
void el.offsetHeight;
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
leave(el, done) {
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
void el.offsetHeight;
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
if (text) {
|
||||
showHoverTooltip(text, event);
|
||||
@@ -791,19 +768,6 @@ a.sidebar-link-button {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
will-change: opacity, max-height;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.source-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -41,27 +41,29 @@
|
||||
keyboard_arrow_down
|
||||
</i>
|
||||
</div>
|
||||
<div class="settings-card-sub-item" :class="{ 'settings-card-collapsible--expanded': expandedSections[setting.id] }">
|
||||
<div class="settings-card-sub-item-inner">
|
||||
<div
|
||||
v-for="section in visibleSections(setting)"
|
||||
:key="section.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="settings-card-collapsible-sub-item settings-card clickable"
|
||||
:class="{ 'active-settings': active(`${setting.id}-${section.id}`) }"
|
||||
@click.stop="setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.enter.stop.prevent="!$event.repeat && setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.space.stop.prevent
|
||||
@keyup.space.stop="setView(`${setting.id}-${section.id}`)"
|
||||
>
|
||||
<span class="settings-item-content">
|
||||
<span class="material-symbols-outlined settings-icon">{{ section.icon }}</span>
|
||||
{{ $t(section.label, { suffix: "" }) }}
|
||||
</span>
|
||||
<transition :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<div v-show="expandedSections[setting.id]" class="settings-card-sub-item">
|
||||
<div class="settings-card-sub-item-inner">
|
||||
<div
|
||||
v-for="section in visibleSections(setting)"
|
||||
:key="section.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="settings-card-collapsible-sub-item settings-card clickable"
|
||||
:class="{ 'active-settings': active(`${setting.id}-${section.id}`) }"
|
||||
@click.stop="setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.enter.stop.prevent="!$event.repeat && setView(`${setting.id}-${section.id}`)"
|
||||
@keydown.space.stop.prevent
|
||||
@keyup.space.stop="setView(`${setting.id}-${section.id}`)"
|
||||
>
|
||||
<span class="settings-item-content">
|
||||
<span class="material-symbols-outlined settings-icon">{{ section.icon }}</span>
|
||||
{{ $t(section.label, { suffix: "" }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -90,6 +92,7 @@ import { state, getters, mutations } from "@/store";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { settings } from "@/utils/constants";
|
||||
import { router } from "@/router";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions";
|
||||
|
||||
export default {
|
||||
name: "SidebarSettings",
|
||||
@@ -122,6 +125,8 @@ export default {
|
||||
requestAnimationFrame(() => this.syncExpanded());
|
||||
},
|
||||
methods: {
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
syncExpanded() {
|
||||
for (const setting of this.expandableSettings) {
|
||||
this.expandedSections[setting.id] = this.isSectionActive(setting) && this.canExpand(setting);
|
||||
@@ -231,21 +236,7 @@ export default {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.settings-card-sub-item {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.settings-card-collapsible--expanded {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.settings-card-sub-item-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-card-collapsible--expanded .settings-card-sub-item-inner {
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<nav
|
||||
id="sidebar"
|
||||
:class="{ active: active, 'behind-overlay': behindOverlay, 'scrollable': isSettings }"
|
||||
:style="{ width: `${sidebarWidth}em`, left: active ? '0' : `-${sidebarWidth}em` }"
|
||||
:style="{ width: 'var(--sidebar-width)', translate: active ? 'none' : '-100%' }"
|
||||
>
|
||||
<div v-if="shouldShow" class="button release-banner">
|
||||
<a :href="releaseUrl">{{ $t("sidebar.updateIsAvailable") }}</a>
|
||||
@@ -44,6 +44,9 @@
|
||||
<div v-if="active && !isMobile" class="sidebar-resizer" @mousedown="startResize" @touchstart="startResize" >
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="isResizing" class="sidebar-is-resizing"></div>
|
||||
</Teleport>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -64,6 +67,9 @@ export default {
|
||||
return {
|
||||
resizeStartX: 0,
|
||||
resizeStartWidth: 0,
|
||||
resizeBaseFontSize: 16,
|
||||
resizePendingWidth: 0,
|
||||
resizeFrame: 0,
|
||||
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
|
||||
pwaInstallDismissed: localStorage.getItem("pwaInstallDismissed") === "true",
|
||||
};
|
||||
@@ -94,7 +100,12 @@ export default {
|
||||
document.addEventListener('touchend', this.stopResize);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up event listener
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
if (state.sidebar.isResizing) {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${state.sidebar.width}em`);
|
||||
mutations.setSidebarResizing(false);
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
}
|
||||
if (this.handleKeydown) {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
@@ -122,6 +133,7 @@ export default {
|
||||
active: () => getters.isSidebarVisible(),
|
||||
behindOverlay: () => state.isSearchActive || (state.prompts && state.prompts.length > 0),
|
||||
sidebarWidth: () => getters.sidebarWidth(),
|
||||
isResizing: () => state.sidebar.isResizing,
|
||||
shouldShow() {
|
||||
return (
|
||||
globalVars.updateAvailable !== "" &&
|
||||
@@ -159,6 +171,8 @@ export default {
|
||||
const clientX = event.clientX || (event.touches?.[0].clientX);
|
||||
this.resizeStartX = clientX;
|
||||
this.resizeStartWidth = this.sidebarWidth;
|
||||
this.resizePendingWidth = this.sidebarWidth;
|
||||
this.resizeBaseFontSize = this.getBaseFontSize();
|
||||
mutations.setSidebarResizing(true);
|
||||
document.body.classList.add('sidebar-resizing');
|
||||
},
|
||||
@@ -168,15 +182,23 @@ export default {
|
||||
// Same here
|
||||
const clientX = event.clientX || (event.touches?.[0].clientX);
|
||||
if (!clientX) return;
|
||||
const deltaX = clientX - this.resizeStartX;
|
||||
// Convert pixels to em
|
||||
const baseFontSize = this.getBaseFontSize();
|
||||
const deltaEm = deltaX / baseFontSize;
|
||||
const deltaEm = (clientX - this.resizeStartX) / this.resizeBaseFontSize;
|
||||
const newWidth = this.resizeStartWidth + deltaEm;
|
||||
mutations.setSidebarWidth(newWidth);
|
||||
this.resizePendingWidth = Math.max(state.sidebar.minWidth, Math.min(newWidth, state.sidebar.maxWidth));
|
||||
if (!this.resizeFrame) {
|
||||
this.resizeFrame = requestAnimationFrame(this.applyResizeWidth);
|
||||
}
|
||||
},
|
||||
// Only the CSS variable moves during the drag, so no component re-renders per mouse move.
|
||||
applyResizeWidth() {
|
||||
this.resizeFrame = 0;
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${this.resizePendingWidth}em`);
|
||||
},
|
||||
stopResize() {
|
||||
if (!state.sidebar.isResizing) return;
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
this.applyResizeWidth();
|
||||
mutations.setSidebarWidth(this.resizePendingWidth);
|
||||
mutations.setSidebarResizing(false);
|
||||
mutations.persistSidebarWidth();
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
@@ -209,22 +231,24 @@ export default {
|
||||
padding: 1em;
|
||||
width: 20em;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
transform: translateZ(0);
|
||||
height: 100%;
|
||||
transition: left 0.4s ease, width 0.4s ease, z-index 0s linear 0.2s;
|
||||
/* `translate` slides on the compositor; animating `left`/`width` re-lays out every frame. */
|
||||
transition: translate 0.22s cubic-bezier(0.2, 0, 0, 1), z-index 0s linear 0.11s;
|
||||
top: var(--header-height);
|
||||
padding-bottom: 4em;
|
||||
background-color: var(--panel-bg);
|
||||
backdrop-filter: var(--panel-blur);
|
||||
will-change: left;
|
||||
will-change: translate;
|
||||
backface-visibility: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
#sidebar.behind-overlay {
|
||||
z-index: 3;
|
||||
transition: left 0.4s ease, width 0.4s ease, z-index 0s;
|
||||
transition: translate 0.22s cubic-bezier(0.2, 0, 0, 1), z-index 0s;
|
||||
}
|
||||
|
||||
#sidebar.sticky {
|
||||
@@ -254,10 +278,14 @@ body.rtl .action {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
body.sidebar-resizing,
|
||||
body.sidebar-resizing * {
|
||||
cursor: col-resize !important;
|
||||
pointer-events: none;
|
||||
.sidebar-is-resizing {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
body.sidebar-resizing :is(#main, #status-bar, #shelf, .progress-line, .nav-button) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -265,10 +293,6 @@ body.sidebar-resizing * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* * * * * * * * * * * * * * * *
|
||||
* FOOTER *
|
||||
* * * * * * * * * * * * * * * */
|
||||
|
||||
.credits {
|
||||
font-size: 1em;
|
||||
color: var(--textPrimary);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/store', () => ({ state: { mediaPlayerSettings: null } }));
|
||||
vi.mock('@/store', () => ({ state: { mediaSettings: null } }));
|
||||
vi.mock('@/utils/constants', () => ({
|
||||
globalVars: { ffmpegAvailable: true, transcodeEnabled: true },
|
||||
globalVars: { ffmpegAvailable: true },
|
||||
}));
|
||||
|
||||
import { state } from '@/store/index.ts';
|
||||
@@ -11,35 +11,31 @@ import { isTranscodeAvailable } from '@/plyr/transcodeMode.js';
|
||||
|
||||
describe('isTranscodeAvailable', () => {
|
||||
beforeEach(() => {
|
||||
state.mediaPlayerSettings = null;
|
||||
state.mediaSettings = null;
|
||||
globalVars.ffmpegAvailable = true;
|
||||
globalVars.transcodeEnabled = true;
|
||||
});
|
||||
|
||||
it('falls back to the page constant before settings load', () => {
|
||||
expect(isTranscodeAvailable()).toBe(true);
|
||||
|
||||
globalVars.transcodeEnabled = false;
|
||||
it('stays off until live media settings load', () => {
|
||||
expect(isTranscodeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the live media settings over the page constant', () => {
|
||||
state.mediaPlayerSettings = { enabled: false };
|
||||
it('follows the live media settings', () => {
|
||||
state.mediaSettings = { enabled: false };
|
||||
expect(isTranscodeAvailable()).toBe(false);
|
||||
|
||||
state.mediaPlayerSettings = { enabled: true };
|
||||
state.mediaSettings = { enabled: true };
|
||||
expect(isTranscodeAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('stays off when the server has no ffmpeg', () => {
|
||||
globalVars.ffmpegAvailable = false;
|
||||
globalVars.transcodeEnabled = false;
|
||||
state.mediaSettings = { enabled: true };
|
||||
expect(isTranscodeAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays off when ffmpeg is missing even if settings claim enabled', () => {
|
||||
globalVars.ffmpegAvailable = false;
|
||||
state.mediaPlayerSettings = { enabled: true };
|
||||
state.mediaSettings = { enabled: true };
|
||||
expect(isTranscodeAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,17 @@ import { state } from '@/store/index.ts';
|
||||
import { globalVars } from '@/utils/constants.js';
|
||||
|
||||
/**
|
||||
* `globalVars` are static page constants, and `ffmpegAvailable` mirrors ffmpeg
|
||||
* availability, which cannot change without a restart. Transcoding is ffmpeg-only,
|
||||
* so it gates the feature outright. The transcode toggle itself is read from the
|
||||
* live media settings so an admin change applies without a page reload.
|
||||
* `ffmpegAvailable` is a static page constant that mirrors ffmpeg availability, which
|
||||
* cannot change without a restart. Transcoding is ffmpeg-only, so it gates the feature
|
||||
* outright. The transcode toggle is read only from the live media settings, so an admin
|
||||
* change applies without a page reload. Before those settings load (or for anonymous
|
||||
* users and shares, which never load them) transcoding reports unavailable.
|
||||
*/
|
||||
export function isTranscodeAvailable() {
|
||||
if (globalVars?.ffmpegAvailable !== true) {
|
||||
return false;
|
||||
}
|
||||
const live = state?.mediaPlayerSettings;
|
||||
if (live) {
|
||||
return live.enabled === true;
|
||||
}
|
||||
return globalVars.transcodeEnabled === true;
|
||||
return state?.mediaSettings?.enabled === true;
|
||||
}
|
||||
|
||||
/** Supported transcode playback modes in the player UI. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { markRaw } from "vue";
|
||||
import { resourcesApi, usersApi } from "@/api/index.js";
|
||||
import {
|
||||
getEnforcedUserDefaults,
|
||||
getMediaPlayerSettings,
|
||||
getSharedMediaSettings,
|
||||
getShareDefaultsPolicy,
|
||||
getSidebarLinkDefaultsPolicy,
|
||||
getToolAccessDefaultsPolicy,
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
} from "@/utils/uploadNavigationGuard.ts";
|
||||
import { getters } from "./getters.ts";
|
||||
import { state } from "./state.ts";
|
||||
|
||||
/** Monotonic id so stale syncMediaSettings responses cannot overwrite newer state. */
|
||||
let mediaSettingsSyncGeneration = 0;
|
||||
import { rawList, withEntry, withoutEntry } from "@/utils/listing/selection.ts";
|
||||
import type {
|
||||
DisplayPreference,
|
||||
@@ -701,25 +704,33 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Load the effective media settings for playback. `globalVars` are static
|
||||
* page constants, so runtime changes to media settings are read from the API
|
||||
* (available to every authenticated user) instead.
|
||||
* Load the runtime media settings. Runtime admin changes are read from the API
|
||||
* rather than static page constants. Called at app start so the state is present
|
||||
* in every view, not only the player.
|
||||
*/
|
||||
syncMediaPlayerSettings: async () => {
|
||||
syncMediaSettings: async () => {
|
||||
if (!getters.isLoggedIn() || getters.isShare() || state.user?.username === "anonymous") {
|
||||
state.mediaPlayerSettings = null;
|
||||
mediaSettingsSyncGeneration += 1;
|
||||
state.mediaSettings = null;
|
||||
return;
|
||||
}
|
||||
const generation = ++mediaSettingsSyncGeneration;
|
||||
try {
|
||||
const settings = await getMediaPlayerSettings();
|
||||
const settings = await getSharedMediaSettings();
|
||||
if (generation !== mediaSettingsSyncGeneration) {
|
||||
return;
|
||||
}
|
||||
// The request can resolve after logout/share transition — don't write
|
||||
// media settings into a logged-out state.
|
||||
if (!getters.isLoggedIn() || getters.isShare() || state.user?.username === "anonymous") {
|
||||
return;
|
||||
}
|
||||
state.mediaPlayerSettings = settings;
|
||||
state.mediaSettings = settings;
|
||||
} catch {
|
||||
state.mediaPlayerSettings = null;
|
||||
if (generation !== mediaSettingsSyncGeneration) {
|
||||
return;
|
||||
}
|
||||
state.mediaSettings = null;
|
||||
}
|
||||
},
|
||||
syncSidebarLinkDefaultsPolicy: async () => {
|
||||
|
||||
@@ -35,8 +35,8 @@ export const state: StoreState = reactive({
|
||||
showSidebar: false,
|
||||
displayPreferences: {},
|
||||
enforcedUserDefaults: {},
|
||||
/** Effective media settings for playback; null until the API reports them. */
|
||||
mediaPlayerSettings: null,
|
||||
/** Runtime media state shared by the player and general UI; null until loaded (logged-in users only). */
|
||||
mediaSettings: null,
|
||||
sidebarLinkDefaultsPolicy: { items: [] },
|
||||
toolAccessDefaultsPolicy: { items: [] },
|
||||
shareDefaultsPolicy: { values: {}, enforced: {} },
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ReqObject {
|
||||
parentDirItems?: FileListItem[];
|
||||
onlyOfficeId?: string;
|
||||
hasUpdate?: boolean;
|
||||
isDir?: boolean;
|
||||
metadata?: unknown;
|
||||
|
||||
// Directory listing properties
|
||||
@@ -92,6 +93,7 @@ export interface ShareInfoObject {
|
||||
allowCreate?: boolean;
|
||||
allowReplacements?: boolean;
|
||||
allowDelete?: boolean;
|
||||
canEditShare?: boolean;
|
||||
disableDownload?: boolean;
|
||||
showHidden?: boolean;
|
||||
quotaLimitBytes?: number;
|
||||
@@ -237,6 +239,7 @@ export interface UserObject {
|
||||
debugOffice: boolean;
|
||||
preferEditorForMarkdown: boolean;
|
||||
showCopyPath?: boolean;
|
||||
showSelectMultiple?: boolean;
|
||||
showTypeColumn?: boolean;
|
||||
showCreationDateColumn?: boolean;
|
||||
hideFileExt?: string;
|
||||
@@ -334,12 +337,13 @@ export interface StoreState {
|
||||
newFileTemplate?: boolean;
|
||||
};
|
||||
};
|
||||
/** Effective media settings for playback, or null before the API reports them. */
|
||||
mediaPlayerSettings: {
|
||||
/** Runtime media state for all UI (player, listings, previews); null until loaded. */
|
||||
mediaSettings: {
|
||||
enabled: boolean;
|
||||
profiles: string[];
|
||||
maxConcurrentPerUser: number;
|
||||
hardwareAcceleration: boolean;
|
||||
/** Per image preview type (e.g. heic, jxl): conversion active on this server. */
|
||||
imagePreview: Partial<Record<'heic' | 'jxl' | 'jpeg', boolean>>;
|
||||
} | null;
|
||||
sidebarLinkDefaultsPolicy: {
|
||||
items?: Array<{
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function validateLogin(isPublicRoute = false) {
|
||||
const userInfo = await res.json();
|
||||
await mutations.setCurrentUser(userInfo);
|
||||
await mutations.syncEnforcedUserDefaults();
|
||||
await mutations.syncMediaPlayerSettings();
|
||||
await mutations.syncMediaSettings();
|
||||
await mutations.syncSidebarLinkDefaultsPolicy();
|
||||
await mutations.syncToolAccessDefaultsPolicy();
|
||||
await mutations.syncShareDefaultsPolicy();
|
||||
|
||||
@@ -8,7 +8,7 @@ const { storeMock } = vi.hoisted(() => ({
|
||||
isLoggedIn: vi.fn(() => true),
|
||||
isShare: vi.fn(() => false),
|
||||
},
|
||||
mutations: { setCurrentUser: vi.fn(), setSession: vi.fn(), syncEnforcedUserDefaults: vi.fn(), syncMediaPlayerSettings: vi.fn(), syncSidebarLinkDefaultsPolicy: vi.fn(), syncToolAccessDefaultsPolicy: vi.fn(), syncShareDefaultsPolicy: vi.fn() },
|
||||
mutations: { setCurrentUser: vi.fn(), setSession: vi.fn(), syncEnforcedUserDefaults: vi.fn(), syncMediaSettings: vi.fn(), syncSidebarLinkDefaultsPolicy: vi.fn(), syncToolAccessDefaultsPolicy: vi.fn(), syncShareDefaultsPolicy: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/* Height/opacity expand transition hooks */
|
||||
|
||||
export interface ExpandEnterOptions {
|
||||
/** Return a max height (px) to make the element scrollable */
|
||||
getMaxHeight?: (fullHeight: number, fullWidth: number) => number | null | undefined;
|
||||
/** Called with the measured size before the animation starts */
|
||||
onMeasured?: (fullHeight: number, fullWidth: number) => void;
|
||||
}
|
||||
|
||||
export function expandBeforeEnter(node: Element): void {
|
||||
const el = node as HTMLElement;
|
||||
el.style.height = "0";
|
||||
el.style.opacity = "0";
|
||||
}
|
||||
|
||||
export function expandEnter(
|
||||
node: Element,
|
||||
done: () => void,
|
||||
durationMs = 300,
|
||||
options: ExpandEnterOptions = {},
|
||||
): void {
|
||||
const el = node as HTMLElement;
|
||||
const { getMaxHeight, onMeasured } = options;
|
||||
el.style.transition = "";
|
||||
el.style.height = "0";
|
||||
el.style.opacity = "0";
|
||||
void el.offsetHeight;
|
||||
el.style.height = "auto";
|
||||
el.style.visibility = "hidden";
|
||||
void el.offsetHeight;
|
||||
const fullHeight = el.scrollHeight;
|
||||
const fullWidth = el.scrollWidth;
|
||||
|
||||
if (onMeasured) onMeasured(fullHeight, fullWidth);
|
||||
|
||||
const result = getMaxHeight ? getMaxHeight(fullHeight, fullWidth) : null;
|
||||
const maxHeight = typeof result === "number" ? result : null;
|
||||
const scrollable = maxHeight !== null && fullHeight > maxHeight;
|
||||
const targetHeight = scrollable ? maxHeight : fullHeight;
|
||||
el.style.overflowY = scrollable ? "auto" : "";
|
||||
el.style.overflowX = scrollable ? "hidden" : "";
|
||||
el.style.justifyContent = scrollable ? "flex-start" : "";
|
||||
|
||||
el.style.height = "0";
|
||||
el.style.visibility = "visible";
|
||||
el.style.transition = `height ${durationMs}ms, opacity ${durationMs}ms`;
|
||||
void el.offsetHeight;
|
||||
el.style.height = `${targetHeight}px`;
|
||||
el.style.opacity = "1";
|
||||
setTimeout(done, durationMs);
|
||||
}
|
||||
|
||||
export function expandLeave(node: Element, done: () => void, durationMs = 300): void {
|
||||
const el = node as HTMLElement;
|
||||
el.style.transition = `height ${durationMs}ms, opacity ${durationMs}ms`;
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
void el.offsetHeight;
|
||||
el.style.height = "0";
|
||||
el.style.opacity = "0";
|
||||
setTimeout(done, durationMs);
|
||||
}
|
||||
@@ -67,7 +67,6 @@
|
||||
"file_copy",
|
||||
"file_download",
|
||||
"file_json",
|
||||
"file_upload",
|
||||
"flutter",
|
||||
"folder",
|
||||
"folder_open",
|
||||
@@ -160,7 +159,6 @@
|
||||
"slideshow",
|
||||
"snowflake",
|
||||
"speed",
|
||||
"storage",
|
||||
"sync",
|
||||
"sync_problem",
|
||||
"table",
|
||||
|
||||
@@ -88,13 +88,16 @@ export function enabledTranscodeProfileIds(media) {
|
||||
* the full object to admins and a player-only subset to everyone else, so both
|
||||
* shapes collapse to the same fields here.
|
||||
*/
|
||||
export function normalizeMediaPlayerSettings(data) {
|
||||
export function normalizeSharedMediaSettings(data) {
|
||||
// Both payloads share the transcode keys: enabled, maxConcurrentPerUser, and the
|
||||
// derived enabledProfiles. Admin payloads also carry the full profiles object, so
|
||||
// fall back to deriving the ids from it when enabledProfiles is absent.
|
||||
const transcode = data?.transcode || {};
|
||||
return {
|
||||
enabled: Boolean(data?.enabled ?? transcode.enabled),
|
||||
profiles: Array.isArray(data?.profiles) ? data.profiles : enabledTranscodeProfileIds(data),
|
||||
maxConcurrentPerUser: Number(data?.maxConcurrentPerUser ?? transcode.maxConcurrentPerUser) || 1,
|
||||
hardwareAcceleration: Boolean(data?.hardwareAcceleration),
|
||||
enabled: Boolean(transcode.enabled),
|
||||
profiles: Array.isArray(transcode.enabledProfiles) ? transcode.enabledProfiles : enabledTranscodeProfileIds(data),
|
||||
maxConcurrentPerUser: Number(transcode.maxConcurrentPerUser) || 1,
|
||||
imagePreview: { ...(data?.imagePreview || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { patchMediaSettings } from '@/api/settings.js';
|
||||
import {
|
||||
enabledTranscodeProfileIds,
|
||||
mergeMediaSettings,
|
||||
normalizeMediaPlayerSettings,
|
||||
normalizeSharedMediaSettings,
|
||||
saveMediaSettingsPatch,
|
||||
stripLockedMediaPaths,
|
||||
} from '@/utils/mediaSettings.js';
|
||||
@@ -104,7 +104,7 @@ describe('mediaSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMediaPlayerSettings', () => {
|
||||
describe('normalizeSharedMediaSettings', () => {
|
||||
it('derives enabled profiles in menu order from the admin settings shape', () => {
|
||||
const media = {
|
||||
transcode: {
|
||||
@@ -116,37 +116,34 @@ describe('mediaSettings', () => {
|
||||
datasaver: { enabled: true },
|
||||
},
|
||||
},
|
||||
hardwareAcceleration: true,
|
||||
};
|
||||
expect(enabledTranscodeProfileIds(media)).toEqual(['quality', 'datasaver']);
|
||||
expect(normalizeMediaPlayerSettings(media)).toEqual({
|
||||
expect(normalizeSharedMediaSettings(media)).toEqual({
|
||||
enabled: true,
|
||||
profiles: ['quality', 'datasaver'],
|
||||
maxConcurrentPerUser: 2,
|
||||
hardwareAcceleration: true,
|
||||
imagePreview: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the player-only shape returned to non-admin users', () => {
|
||||
expect(normalizeMediaPlayerSettings({
|
||||
enabled: false,
|
||||
profiles: ['quality'],
|
||||
maxConcurrentPerUser: 3,
|
||||
hardwareAcceleration: false,
|
||||
expect(normalizeSharedMediaSettings({
|
||||
transcode: { enabled: false, maxConcurrentPerUser: 3, enabledProfiles: ['quality'] },
|
||||
imagePreview: { heic: true },
|
||||
})).toEqual({
|
||||
enabled: false,
|
||||
profiles: ['quality'],
|
||||
maxConcurrentPerUser: 3,
|
||||
hardwareAcceleration: false,
|
||||
imagePreview: { heic: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a per-user limit of one and disabled when values are absent', () => {
|
||||
expect(normalizeMediaPlayerSettings(undefined)).toEqual({
|
||||
expect(normalizeSharedMediaSettings(undefined)).toEqual({
|
||||
enabled: false,
|
||||
profiles: [],
|
||||
maxConcurrentPerUser: 1,
|
||||
hardwareAcceleration: false,
|
||||
imagePreview: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
const POP_SCALE_X = 0.75;
|
||||
const POP_SCALE_Y = 0.5625;
|
||||
const POP_EASING_IN = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
const POP_EASING_OUT = "cubic-bezier(0.4, 0, 1, 1)";
|
||||
|
||||
/** `scale` val for the progress: 0 = collapsed, 1 = fully shown */
|
||||
function popScale(progress: number, fromScaleX: number): string {
|
||||
return `${fromScaleX + (1 - fromScaleX) * progress} ${POP_SCALE_Y + (1 - POP_SCALE_Y) * progress}`;
|
||||
}
|
||||
|
||||
// Running animation for each element
|
||||
const running = new WeakMap<Element, Animation>();
|
||||
let reducedMotionQuery: MediaQueryList | null | undefined;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (reducedMotionQuery === undefined) {
|
||||
reducedMotionQuery =
|
||||
typeof window !== "undefined" && window.matchMedia
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||
: null;
|
||||
}
|
||||
return !!reducedMotionQuery?.matches;
|
||||
}
|
||||
|
||||
interface AnimateOptions {
|
||||
/** Set while the animation runs */
|
||||
willChange?: string;
|
||||
/** Keeps the end state after finishing until the element is removed */
|
||||
fill?: FillMode;
|
||||
easing: string;
|
||||
}
|
||||
|
||||
function animate(
|
||||
el: HTMLElement,
|
||||
from: Keyframe,
|
||||
to: Keyframe,
|
||||
durationMs: number,
|
||||
done: () => void,
|
||||
{ willChange, fill, easing }: AnimateOptions,
|
||||
): void {
|
||||
running.get(el)?.cancel();
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
done();
|
||||
};
|
||||
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
if (typeof el.animate !== "function" || reduceMotion) {
|
||||
setTimeout(finish, reduceMotion ? 0 : durationMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (willChange) el.style.willChange = willChange;
|
||||
const animation = el.animate([from, to], { duration: durationMs, easing, fill });
|
||||
running.set(el, animation);
|
||||
const release = () => {
|
||||
if (running.get(el) === animation) {
|
||||
running.delete(el);
|
||||
if (willChange) el.style.willChange = "";
|
||||
}
|
||||
finish();
|
||||
if (fill) {
|
||||
animation.oncancel = null;
|
||||
animation.cancel();
|
||||
}
|
||||
};
|
||||
animation.onfinish = release;
|
||||
animation.oncancel = release;
|
||||
}
|
||||
|
||||
function runningProgress(el: Element): number | null {
|
||||
const animation = running.get(el);
|
||||
if (!animation) return null;
|
||||
const progress = animation.effect?.getComputedTiming().progress;
|
||||
return typeof progress === "number" ? progress : 0;
|
||||
}
|
||||
|
||||
export function popEnter(node: Element, done: () => void, durationMs = 200, fromScaleX = POP_SCALE_X): void {
|
||||
const el = node as HTMLElement;
|
||||
el.style.opacity = "";
|
||||
animate(el, { opacity: 0, scale: popScale(0, fromScaleX) }, { opacity: 1, scale: popScale(1, fromScaleX) }, durationMs, done, {
|
||||
willChange: "opacity, scale",
|
||||
easing: POP_EASING_IN,
|
||||
});
|
||||
}
|
||||
|
||||
export function popLeave(node: Element, done: () => void, durationMs = 120, fromScaleX = POP_SCALE_X): void {
|
||||
const el = node as HTMLElement;
|
||||
const progress = runningProgress(el) ?? 1;
|
||||
el.style.opacity = "0";
|
||||
animate(
|
||||
el,
|
||||
{ opacity: progress, scale: popScale(progress, fromScaleX) },
|
||||
{ opacity: 0, scale: popScale(0, fromScaleX) },
|
||||
durationMs,
|
||||
() => {
|
||||
done();
|
||||
el.style.opacity = "";
|
||||
},
|
||||
{ willChange: "opacity, scale", fill: "forwards", easing: POP_EASING_OUT },
|
||||
);
|
||||
}
|
||||
|
||||
const SLIDE_PX = 8;
|
||||
|
||||
function slideTranslate(progress: number): string {
|
||||
return `0 ${-SLIDE_PX * (1 - progress)}px`;
|
||||
}
|
||||
|
||||
export function slideEnter(node: Element, done: () => void, durationMs = 180): void {
|
||||
const el = node as HTMLElement;
|
||||
el.style.opacity = "";
|
||||
animate(el, { opacity: 0, translate: slideTranslate(0) }, { opacity: 1, translate: slideTranslate(1) }, durationMs, done, {
|
||||
willChange: "opacity, translate",
|
||||
easing: POP_EASING_IN,
|
||||
});
|
||||
}
|
||||
|
||||
export function slideLeave(node: Element, done: () => void, durationMs = 120): void {
|
||||
const el = node as HTMLElement;
|
||||
const progress = runningProgress(el) ?? 1;
|
||||
el.style.opacity = "0";
|
||||
animate(
|
||||
el,
|
||||
{ opacity: progress, translate: slideTranslate(progress) },
|
||||
{ opacity: 0, translate: slideTranslate(0) },
|
||||
durationMs,
|
||||
() => {
|
||||
done();
|
||||
el.style.opacity = "";
|
||||
},
|
||||
{ willChange: "opacity, translate", fill: "forwards", easing: POP_EASING_OUT },
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse height, vertical padding, margin and opacity go to zero together
|
||||
// (this is used in the collapsibles in settings. (but is a bit expensive)
|
||||
const COLLAPSE_MS_IN = 200;
|
||||
const COLLAPSE_MS_OUT = 160;
|
||||
|
||||
function collapsedFrame(opacity: number): Keyframe {
|
||||
return { height: "0px", paddingTop: "0px", paddingBottom: "0px", marginTop: "0px", marginBottom: "0px", opacity };
|
||||
}
|
||||
|
||||
/** The element as is rn */
|
||||
function currentFrame(el: HTMLElement): Keyframe {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
height: style.height,
|
||||
paddingTop: style.paddingTop,
|
||||
paddingBottom: style.paddingBottom,
|
||||
marginTop: style.marginTop,
|
||||
marginBottom: style.marginBottom,
|
||||
opacity: style.opacity,
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(el: HTMLElement, from: Keyframe, to: Keyframe, durationMs: number, done: () => void, fill?: FillMode): void {
|
||||
el.style.overflow = "hidden";
|
||||
animate(
|
||||
el,
|
||||
from,
|
||||
to,
|
||||
durationMs,
|
||||
() => {
|
||||
done();
|
||||
if (!running.has(el)) el.style.overflow = "";
|
||||
},
|
||||
{ fill, easing: POP_EASING_IN },
|
||||
);
|
||||
}
|
||||
|
||||
export function collapseEnter(node: Element, done: () => void, durationMs = COLLAPSE_MS_IN): void {
|
||||
const el = node as HTMLElement;
|
||||
el.style.opacity = "";
|
||||
const from = running.has(el) ? currentFrame(el) : collapsedFrame(0);
|
||||
running.get(el)?.cancel();
|
||||
collapse(el, from, { ...currentFrame(el), opacity: 1 }, durationMs, done);
|
||||
}
|
||||
|
||||
export function collapseLeave(node: Element, done: () => void, durationMs = COLLAPSE_MS_OUT): void {
|
||||
const el = node as HTMLElement;
|
||||
const from = currentFrame(el);
|
||||
collapse(el, from, collapsedFrame(0), durationMs, done, "forwards");
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export default {
|
||||
width: `${this.loadingProgress}%`,
|
||||
};
|
||||
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
|
||||
style.left = `${state.sidebar.width}em`;
|
||||
style.left = "var(--sidebar-width)";
|
||||
}
|
||||
return style;
|
||||
},
|
||||
@@ -826,7 +826,7 @@ export default {
|
||||
height: 1px;
|
||||
background: var(--primaryColor);
|
||||
z-index: 2000;
|
||||
transition: width 0.3s ease, left 0.2s ease;
|
||||
transition: width 0.3s ease, left 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
box-shadow: 0 0 10px var(--primaryColor);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="{ 'sidebar-overlay': floatingSidebar }" @dragover.prevent @drop.prevent>
|
||||
<transition name="overlay-fade">
|
||||
<div v-show="showOverlay" @contextmenu.prevent="resetItems" @click="resetItems" class="overlay"></div>
|
||||
<div v-show="showOverlay" @contextmenu.prevent="resetItems" @click="resetItems" class="overlay" :class="{ 'overlay-menu': menuOnlyOpen }"></div>
|
||||
</transition>
|
||||
<transition name="overlay-fade">
|
||||
<div v-if="overlayOverPrompt" @contextmenu.prevent="closeContextMenu" @click="closeContextMenu" class="overlay overlay-prompt"></div>
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
const shouldMove = getters.isSidebarVisible() && getters.isStickySidebar();
|
||||
return {
|
||||
shouldMove,
|
||||
style: shouldMove ? { paddingLeft: `${state.sidebar.width}em` } : {}
|
||||
style: shouldMove ? { paddingLeft: "var(--sidebar-width)" } : {}
|
||||
};
|
||||
},
|
||||
spaceForEditorStatusBar() {
|
||||
@@ -143,9 +143,19 @@ export default {
|
||||
showOverlay() {
|
||||
return getters.showOverlay();
|
||||
},
|
||||
sidebarWidth() {
|
||||
return getters.sidebarWidth();
|
||||
},
|
||||
floatingSidebar() {
|
||||
return getters.isSidebarVisible() && !getters.isStickySidebar() && !state.prompts.length && !state.isSearchActive;
|
||||
},
|
||||
// True while the only thing open is a context/overflow menu (no dialog underneath).
|
||||
menuOnlyOpen() {
|
||||
return (
|
||||
state.prompts.length > 0 &&
|
||||
state.prompts.every((prompt) => prompt.name === "ContextMenu" || prompt.name === "OverflowMenu")
|
||||
);
|
||||
},
|
||||
overlayOverPrompt() {
|
||||
if (getters.currentPromptName() !== "ContextMenu") return false;
|
||||
return state.prompts.length > 1 || state.isSearchActive;
|
||||
@@ -180,6 +190,12 @@ export default {
|
||||
syncDocumentTheme(dark);
|
||||
},
|
||||
},
|
||||
sidebarWidth: {
|
||||
immediate: true,
|
||||
handler(width) {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${width}em`);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
reEval() {
|
||||
@@ -301,18 +317,10 @@ export default {
|
||||
#main {
|
||||
overflow: unset;
|
||||
scrollbar-width: none;
|
||||
transition: padding-left 0.2s ease;
|
||||
transition: padding-left 0.22s cubic-bezier(0.2, 0, 0, 1);
|
||||
color: var(--textPrimary);
|
||||
}
|
||||
|
||||
#main.moveWithSidebar {
|
||||
transition: padding-left 0.2s ease;
|
||||
}
|
||||
|
||||
#main.moveWithSidebar.resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div v-if="!inProgress" class="login-brand brand-text">
|
||||
<h3>{{ loginName }}</h3>
|
||||
</div>
|
||||
<transition name="login-options" @before-enter="beforeEnter" @enter="enter" @leave="leave">
|
||||
<transition :css="false" @enter="collapseEnter" @leave="collapseLeave">
|
||||
<div v-if="inProgress" class="login-spinner-wrapper">
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
@@ -228,6 +228,7 @@ import { postLoginRedirectForServer, sanitizePostLoginRedirect } from "@/utils/s
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
|
||||
import { defaultDarkMode, syncDocumentTheme } from "@/utils/theme";
|
||||
import { collapseEnter, collapseLeave } from "@/utils/transitions";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import Tooltip from "@/components/Tooltip.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
@@ -333,6 +334,8 @@ export default {
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
collapseEnter,
|
||||
collapseLeave,
|
||||
renderRecaptcha() {
|
||||
const container = document.getElementById("globalVars.recaptcha");
|
||||
if (!container) return;
|
||||
@@ -355,34 +358,6 @@ export default {
|
||||
}
|
||||
return this.$t("general.login");
|
||||
},
|
||||
beforeEnter(el) {
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
},
|
||||
enter(el, done) {
|
||||
el.style.transition = '';
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
// Force reflow
|
||||
void el.offsetHeight;
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
leave(el, done) {
|
||||
el.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
el.style.opacity = '1';
|
||||
// Force reflow
|
||||
void el.offsetHeight;
|
||||
el.style.height = '0';
|
||||
el.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
toggleMode() {
|
||||
this.createMode = !this.createMode;
|
||||
},
|
||||
@@ -599,17 +574,6 @@ export default {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-options-enter-active,
|
||||
.login-options-leave-active {
|
||||
transition: height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.login-options-enter-from,
|
||||
.login-options-leave-to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#login {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@@ -150,7 +150,6 @@ import { isPdfFile } from "@/utils/mediaFile";
|
||||
import { shouldUsePdfPreviewFallback } from "@/utils/pdfPreview.js";
|
||||
import { TranscodePlaybackController } from "@/plyr/transcodePlaybackController.js";
|
||||
import { isTranscodeAvailable } from "@/plyr/transcodeMode.js";
|
||||
|
||||
export default {
|
||||
name: "preview",
|
||||
components: {
|
||||
@@ -208,11 +207,10 @@ export default {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
|
||||
return isIOS && isSafari;
|
||||
},
|
||||
// Viewable when we can get embedded/original preview: (media + heic conversion) or Safari native
|
||||
// Safari: native HEIC in ExtendedImage. Other browsers: converted original when hasPreview.
|
||||
isHeicAndViewable() {
|
||||
if (state.isSafari) return true;
|
||||
if (globalVars.ffmpegAvailable && globalVars.enableHeicConversion) return true;
|
||||
return false;
|
||||
return Boolean(state.req?.hasPreview);
|
||||
},
|
||||
pdfConvertable() {
|
||||
const ext = `.${state.req.name.split(".").pop().toLowerCase()}`;
|
||||
@@ -244,26 +242,10 @@ export default {
|
||||
const typeHint = state.req.type || state.req.name;
|
||||
const isHeicOrHeif = state.req.type === "image/heic" || state.req.type === "image/heif";
|
||||
|
||||
if (state.isSafari && isHeicOrHeif) {
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
}
|
||||
return resourcesApi.getViewURL(state.req.source, state.req.path, viewToken, null, false, typeHint);
|
||||
}
|
||||
|
||||
const getRawPreview = isRawImageMimeType(state.req.type);
|
||||
const getHeicPreview = isHeicOrHeif && globalVars.ffmpegAvailable && globalVars.enableHeicConversion;
|
||||
if (this.pdfConvertable || getRawPreview || getHeicPreview) {
|
||||
// HEIC/HEIF at size=original for non-Safari; Safari full size is resolved in ExtendedImage.
|
||||
const getHeicConvertedPreview = isHeicOrHeif && !state.isSafari && state.req?.hasPreview;
|
||||
if (this.pdfConvertable || getRawPreview || getHeicConvertedPreview) {
|
||||
if (getters.isShare()) {
|
||||
const previewPath = removeTrailingSlash(state.req.path);
|
||||
return resourcesApi.getPreviewURLPublic(previewPath, "original", state.req.viewToken);
|
||||
|
||||
@@ -850,17 +850,9 @@ export default {
|
||||
return needsTranscodeFirst(this.transcodeProbeParams());
|
||||
},
|
||||
transcodeProfileOptions() {
|
||||
const live = state.mediaPlayerSettings?.profiles;
|
||||
let configured;
|
||||
if (Array.isArray(live)) {
|
||||
configured = live;
|
||||
} else {
|
||||
configured = Array.isArray(globalVars.transcodeProfiles)
|
||||
? globalVars.transcodeProfiles
|
||||
: PLYR_TRANSCODE_HLS_MODES;
|
||||
}
|
||||
const ids = configured.filter((id) => PLYR_TRANSCODE_HLS_MODES.includes(id));
|
||||
return ids.length ? ids : PLYR_TRANSCODE_HLS_MODES;
|
||||
const live = state.mediaSettings?.profiles;
|
||||
const configured = Array.isArray(live) ? live : PLYR_TRANSCODE_HLS_MODES;
|
||||
return configured.filter((id) => PLYR_TRANSCODE_HLS_MODES.includes(id));
|
||||
},
|
||||
shouldProactivelyOfferTranscode() {
|
||||
if (this.previewType !== 'video' || this.startTranscode) {
|
||||
@@ -875,7 +867,7 @@ export default {
|
||||
const base = ['captions', 'captionSize', 'quality', 'speed', 'playback', 'loop'];
|
||||
// Register the slot whenever authenticated video can show it later.
|
||||
// Plyr only builds settings panels at mount; applyCustomSettings toggles
|
||||
// visibility when media/live settings settle (e.g. after syncMediaPlayerSettings).
|
||||
// visibility when media/live settings settle (e.g. after syncMediaSettings).
|
||||
if (this.previewType === 'video' && getters.isLoggedIn() && !getters.isShare()) {
|
||||
return [...base, 'transcodeMode'];
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export default {
|
||||
mounted() {
|
||||
this.mediaSettingsHandler = () => {
|
||||
void this.load({ silent: true });
|
||||
// Keep the player's live view of media settings in sync after a save.
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
// Keep the shared live view of media settings in sync after a save.
|
||||
void mutations.syncMediaSettings();
|
||||
};
|
||||
eventBus.on(MEDIA_SETTINGS_CHANGED_EVENT, this.mediaSettingsHandler);
|
||||
void this.load();
|
||||
@@ -200,7 +200,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.media = media;
|
||||
void mutations.syncMediaPlayerSettings();
|
||||
void mutations.syncMediaSettings();
|
||||
notify.showSuccessToast(this.$t("settings.settingsUpdated"));
|
||||
} catch (e) {
|
||||
notify.showError(e?.message || this.$t("media.updateFailed"));
|
||||
|
||||
@@ -11,8 +11,9 @@ seg/00000.m4s
|
||||
`;
|
||||
|
||||
/**
|
||||
* Force the static app-config (and live media settings API) to advertise
|
||||
* transcoding so the plyr mode menu appears even when the CI image lacks ffmpeg.
|
||||
* Force ffmpeg availability in the static app-config and advertise transcoding via
|
||||
* the live media settings API, so the plyr mode menu appears even when the CI image
|
||||
* lacks ffmpeg. Transcode state is read only from the live API.
|
||||
*
|
||||
* Hook getElementById so the patch runs when constants.js reads `#app-config`.
|
||||
* Only rewrite the JSON text — do not replace `window.globalVars` with a new
|
||||
@@ -27,10 +28,6 @@ export async function enableTranscodeUi(page: Page) {
|
||||
try {
|
||||
const cfg = JSON.parse(el.textContent);
|
||||
cfg.ffmpegAvailable = true;
|
||||
cfg.transcodeEnabled = true;
|
||||
if (!Array.isArray(cfg.transcodeProfiles) || cfg.transcodeProfiles.length === 0) {
|
||||
cfg.transcodeProfiles = ["quality", "balanced", "datasaver"];
|
||||
}
|
||||
el.textContent = JSON.stringify(cfg);
|
||||
el.dataset.transcodeUiPatched = "1";
|
||||
} catch (_) {
|
||||
@@ -48,11 +45,14 @@ export async function enableTranscodeUi(page: Page) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
// Matches the PUBLIC UserMedia payload from GET /api/settings/media.
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
profiles: ["quality", "balanced", "datasaver"],
|
||||
maxConcurrentPerUser: 1,
|
||||
hardwareAcceleration: false,
|
||||
transcode: {
|
||||
enabled: true,
|
||||
maxConcurrentPerUser: 1,
|
||||
enabledProfiles: ["quality", "balanced", "datasaver"],
|
||||
},
|
||||
imagePreview: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user