1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

Compare commits

..

139 commits

Author SHA1 Message Date
Chocobozzz
e38c4ba0d1
Remove openapi warnings 2025-09-09 10:56:45 +02:00
Chocobozzz
6a43a72fec
Fix tests build 2025-09-09 10:43:44 +02:00
Chocobozzz
e902f40d3f
Add openapi doc for player settings 2025-09-09 10:03:54 +02:00
Chocobozzz
8384438a36
Add ability to customize player settings 2025-09-09 09:39:46 +02:00
Chocobozzz
b59dc46448
Update dprint HTML module 2025-09-03 09:26:16 +02:00
Chocobozzz
994c53ea9a
Add copilot instructions 2025-09-03 09:26:15 +02:00
Chocobozzz
ce8a6e402e
Introduce lucide player theme 2025-09-03 09:26:15 +02:00
Chocobozzz
e170507000
Add more info to stats card 2025-09-03 09:26:15 +02:00
Chocobozzz
cf621b16cc
Prefer using vertical volume control
Better UX/control
2025-09-03 09:26:15 +02:00
Chocobozzz
8c83592d89
Upgrade to videojs v8 2025-09-03 09:26:15 +02:00
Chocobozzz
91afa1004e
Fill video support on channel sync 2025-09-03 08:46:38 +02:00
Chocobozzz
0882d96624
Do not override privacy for imports and live 2025-09-03 07:13:12 +02:00
Chocobozzz
3ea32ba891
Remove useless help for live transcoding 2025-09-01 09:30:05 +02:00
Chocobozzz
12b4893239
Update translations 2025-08-27 16:59:59 +02:00
Cirnos
aea6983cc4
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.4% (2483 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-27 16:47:38 +02:00
E
65f5bd1c37
Translated using Weblate (Italian)
Currently translated at 89.2% (2536 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-08-27 16:47:38 +02:00
Wuzzy
c5ec42f587
Translated using Weblate (German)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
ButterflyOfFire
741c8f62e0
Translated using Weblate (French (France) (fr_FR))
Currently translated at 99.9% (2839 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-27 16:47:38 +02:00
Weblate
5b1ac25794
Translated using Weblate (French (France) (fr_FR))
Currently translated at 99.9% (2839 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-27 16:47:38 +02:00
Wuzzy
eedcca7879
Translated using Weblate (German)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/de/
2025-08-27 16:47:38 +02:00
Wuzzy
7f70d7f3f2
Translated using Weblate (German)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/de/
2025-08-27 16:47:38 +02:00
Wuzzy
8447769447
Translated using Weblate (German)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/de/
2025-08-27 16:47:38 +02:00
Fjuro
4d45ca6f09
Translated using Weblate (Czech)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/cs/
2025-08-27 16:47:38 +02:00
Wuzzy
77c3a279f7
Translated using Weblate (German)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
Wuzzy
fde057171d
Translated using Weblate (German)
Currently translated at 94.5% (2686 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
Korren-Kerren
6c3d7501e2
Translated using Weblate (Esperanto)
Currently translated at 43.8% (1245 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-27 16:47:38 +02:00
Jeff Huang
1b283c8694
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-27 16:47:38 +02:00
Hồ Nhất Duy
401e5c0b07
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/vi/
2025-08-27 16:47:38 +02:00
Hồ Nhất Duy
7e1701d9d1
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/vi/
2025-08-27 16:47:37 +02:00
偶尔来巡山
06e5f534ba
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-27 16:47:37 +02:00
Hồ Nhất Duy
3ee605b433
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-27 16:47:37 +02:00
Leif-Jöran Olsson
bc0132239e
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-27 16:47:37 +02:00
Jiří Podhorecký
796229ac34
Translated using Weblate (Czech)
Currently translated at 90.8% (2580 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-27 16:47:37 +02:00
Chocobozzz
3d75a7288f
Force to choose a channel to reorder playlists 2025-08-27 16:47:13 +02:00
Chocobozzz
42bf34c29a
Fix "Show" button styling 2025-08-27 16:41:54 +02:00
Chocobozzz
d0e810d29a
Fix retrying the test 2025-08-26 07:46:57 +02:00
Chocobozzz
b7aa685009
Use a better title 2025-08-25 10:36:26 +02:00
Chocobozzz
cfe49b37ec
Fix video title with RSS feed video import 2025-08-25 08:48:03 +02:00
Chocobozzz
f383fe101a
Typo 2025-08-25 08:47:40 +02:00
Chocobozzz
7db2817877
Fix RTL margins on some components 2025-08-21 17:16:23 +02:00
Chocobozzz
24dbbbad64
Fix sending emails in production 2025-08-21 11:49:01 +02:00
Chocobozzz
5894c362d6
Bumped to version v7.3.0-rc.1 2025-08-21 10:24:38 +02:00
Chocobozzz
57667734c6
Fix E2E tests 2025-08-21 10:21:17 +02:00
Chocobozzz
8f852e3153
Manage video playlist on thumbnail click 2025-08-20 10:49:08 +02:00
Chocobozzz
aaae2910e7
Save 2025-08-20 09:00:45 +02:00
Chocobozzz
507cedca1c
Update translations 2025-08-20 09:00:34 +02:00
chocobozzz
a94128a9ce
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/fr_FR/
2025-08-20 08:57:29 +02:00
Marsalis Weatherspoon
d477aa0df6
Translated using Weblate (Spanish)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/es/
2025-08-20 08:57:29 +02:00
chocobozzz
198192aeb4
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/fr_FR/
2025-08-20 08:57:29 +02:00
偶尔来巡山
d1b9a88297
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/zh_Hans/
2025-08-20 08:57:29 +02:00
chocobozzz
2ebd9a74f5
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/fr_FR/
2025-08-20 08:57:29 +02:00
Marsalis Weatherspoon
c2fc1cbbd9
Translated using Weblate (Spanish)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/es/
2025-08-20 08:57:29 +02:00
偶尔来巡山
423633bc2b
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-20 08:57:29 +02:00
Leif-Jöran Olsson
045409fa35
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-20 08:57:29 +02:00
Cirnos
74bb0e58c0
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.3% (2482 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-20 08:57:29 +02:00
Codimp
bf1c1379a2
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-20 08:57:29 +02:00
chocobozzz
c9eb2e2289
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-20 08:57:29 +02:00
Chocobozzz
d090795d0c
Fix useless i18n tag 2025-08-20 08:52:39 +02:00
Chocobozzz
6822bcfcb3
Typo 2025-08-20 08:52:30 +02:00
Chocobozzz
9ae1a0177c
Update translations 2025-08-19 16:28:15 +02:00
Chocobozzz
c26c2c6007
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 16:25:09 +02:00
Alberto
2955824366
Translated using Weblate (Dutch)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/nl/
2025-08-19 16:24:59 +02:00
Alberto
fac0f0bc1d
Translated using Weblate (Dutch)
Currently translated at 84.6% (2379 of 2810 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/nl/
2025-08-19 16:24:59 +02:00
chocobozzz
33aaa7f1d1
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2810 of 2810 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 16:24:58 +02:00
Chocobozzz
2453a82856
Add translation description 2025-08-19 16:13:29 +02:00
Chocobozzz
0967ee953c
Add missing $localize 2025-08-19 16:11:47 +02:00
Chocobozzz
a1a6515524
Update translations 2025-08-19 15:55:54 +02:00
Chocobozzz
0dd0198693
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 15:53:55 +02:00
Leif-Jöran Olsson
6dcdc680e0
Translated using Weblate (Swedish)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/sv/
2025-08-19 15:53:51 +02:00
Chocobozzz
8f0389ab8c
Typo 2025-08-19 15:53:50 +02:00
Leif-Jöran Olsson
035846578f
Translated using Weblate (Swedish)
Currently translated at 100.0% (2811 of 2811 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-19 15:53:49 +02:00
chocobozzz
c858dd9982
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2811 of 2811 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 15:53:48 +02:00
Chocobozzz
789696d22f
Update changelog 2025-08-19 11:53:08 +02:00
Chocobozzz
0bb20d7e19
Update translations 2025-08-19 10:45:47 +02:00
Chocobozzz
e3f0beb6cb
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 10:42:42 +02:00
Cirnos
a383baa812
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.8% (2464 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-19 10:40:36 +02:00
chocobozzz
65f89db861
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2579 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 10:40:35 +02:00
Chocobozzz
b591f42914
Add "copyrighted" licence 2025-08-19 10:30:42 +02:00
Leif-Jöran Olsson
3ca2fec672
Translated using Weblate (Swedish)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/sv/
2025-08-19 06:14:47 +02:00
Leif-Jöran Olsson
263cd2e3d1
Translated using Weblate (Swedish)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-19 06:14:47 +02:00
Kenner Figueiredo
d7ae2daed1
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.8% (2462 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-19 06:14:46 +02:00
Marius Monnier
667dc856ce
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:46 +02:00
rosbeef andino
00b70b84ab
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:46 +02:00
Yelena Bonny
748b940dc4
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:45 +02:00
Chocobozzz
6126aed19e
Update translations 2025-08-18 09:20:33 +02:00
Ettore Atalan
82b92b497f
Translated using Weblate (German)
Currently translated at 93.2% (2615 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-16 17:20:00 +02:00
Fjuro
67f495bdac
Translated using Weblate (Czech)
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-16 17:19:59 +02:00
fran secs
51ecd4c2b0
Translated using Weblate (Catalan)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/ca/
2025-08-15 11:20:07 +02:00
Wuzzy
3561f25806
Translated using Weblate (German)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/de/
2025-08-15 11:20:07 +02:00
Hồ Nhất Duy
05c4e1c43d
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-15 11:20:06 +02:00
Wuzzy
0c5f88a541
Translated using Weblate (German)
Currently translated at 90.4% (2536 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-15 11:20:06 +02:00
fran secs
e631b1d32e
Translated using Weblate (Catalan)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-15 11:20:05 +02:00
Marius Monnier
f6c77d1383
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.0% (2554 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-15 11:20:01 +02:00
偶尔来巡山
3656df036f
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-13 15:30:35 +02:00
Neko Nekowazarashi
bc2b2a6c19
Translated using Weblate (Indonesian)
Currently translated at 34.6% (972 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-13 15:30:34 +02:00
Jeff Huang
54f572fbd0
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-13 15:30:32 +02:00
偶尔来巡山
98ccaec295
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-13 15:30:32 +02:00
Oliwier Jaszczyszyn
ce64ee9c5c
Translated using Weblate (Polish)
Currently translated at 62.7% (1759 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-13 15:30:31 +02:00
Marius Monnier
4222dd6686
Translated using Weblate (French (France) (fr_FR))
Currently translated at 90.2% (2531 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-13 15:30:29 +02:00
Chocobozzz
d7ffde7299
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-12 12:02:09 +02:00
chocobozzz
bcb012977c
Translated using Weblate (Polish)
Currently translated at 59.8% (1662 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-12 12:02:04 +02:00
Chocobozzz
18d05d3a40
Update translations 2025-08-12 11:53:56 +02:00
Korren-Kerren
5a87e008e5
Translated using Weblate (Esperanto)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/eo/
2025-08-12 11:42:45 +02:00
Neko Nekowazarashi
c5f854164c
Translated using Weblate (Indonesian)
Currently translated at 24.6% (684 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-12 11:42:45 +02:00
Korren-Kerren
d301a9f3ae
Translated using Weblate (Esperanto)
Currently translated at 43.7% (1215 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-12 11:42:44 +02:00
alex gabilondo
fd0fc9a5f9
Translated using Weblate (Basque)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/eu/
2025-08-11 17:38:57 +02:00
Fjuro
264558e2ca
Translated using Weblate (Czech)
Currently translated at 90.5% (2515 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-11 17:38:57 +02:00
alex gabilondo
92a4123f25
Translated using Weblate (Basque)
Currently translated at 72.8% (2023 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eu/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
5926787861
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/vi/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
8d262d01b2
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/vi/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
ae9a802403
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-11 17:38:57 +02:00
fran secs
4fd2cdb390
Translated using Weblate (Catalan)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-11 17:38:57 +02:00
fran secs
3edb4b75ee
Translated using Weblate (Catalan)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/ca/
2025-08-11 17:38:57 +02:00
Neko Nekowazarashi
59adaaad69
Translated using Weblate (Indonesian)
Currently translated at 23.3% (648 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-11 17:38:57 +02:00
Александр
82815624b4
Translated using Weblate (Russian)
Currently translated at 82.6% (2294 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ru/
2025-08-11 17:38:57 +02:00
fran secs
1496961c53
Translated using Weblate (Catalan)
Currently translated at 91.4% (2539 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-11 17:38:57 +02:00
偶尔来巡山
18b22257a8
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
chocobozzz
bc1a7d0873
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.6% (2434 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:57 +02:00
chocobozzz
bd6eb388f8
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/fr_FR/
2025-08-11 17:38:57 +02:00
DeepL
71c832f6b2
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.1% (2418 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:57 +02:00
Fabián León
f3f77f596e
Translated using Weblate (Spanish)
Currently translated at 77.9% (2164 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/es/
2025-08-11 17:38:57 +02:00
Denis Dupont
ef690bc792
Translated using Weblate (Esperanto)
Currently translated at 43.7% (1214 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-11 17:38:57 +02:00
dxuser514
ad41b6c06e
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
偶尔来巡山
e10ed4f5ed
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
dxuser514
15867684f5
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/zh_Hans/
2025-08-11 17:38:57 +02:00
chocobozzz
57a8e18022
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.1% (2418 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:56 +02:00
Oliwier Jaszczyszyn
19ec133abb
Translated using Weblate (Polish)
Currently translated at 59.8% (1662 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-11 17:38:56 +02:00
Ettore Atalan
f5d6097980
Translated using Weblate (German)
Currently translated at 88.1% (2446 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-11 17:38:56 +02:00
偶尔来巡山
93f5a7d789
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:56 +02:00
Leif-Jöran Olsson
afc1f0e6b0
Translated using Weblate (Swedish)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/sv/
2025-08-11 17:38:56 +02:00
Jeff Huang
adfa6b43ad
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-11 17:38:56 +02:00
偶尔来巡山
06fd09b93a
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-11 17:38:56 +02:00
Leif-Jöran Olsson
9d9607feff
Translated using Weblate (Swedish)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-11 17:38:56 +02:00
Chocobozzz
d1a35e8421
Disable nginx buffering on upload endpoints
To prevent timeout requests in peertube HTTP server
2025-08-11 17:38:22 +02:00
Chocobozzz
da23ad1d09
Add ability for admin to configure request timeout
AFAIK we can't set a specific timeout on a specific route/request, so
the admin must set it globally
2025-08-11 16:55:37 +02:00
Chocobozzz
b4df49b87f
Fix fetching latest elements of a playlist 2025-08-11 14:52:09 +02:00
Chocobozzz
6fce7c808c
Fix disabling wait transcoding 2025-08-11 14:15:59 +02:00
Chocobozzz
89360a4ef0
Update node 20 minimum version
To support importing ESM modules in plugins
2025-08-11 11:06:22 +02:00
Chocobozzz
acaabaace1
Compat with openid plugin 1.0.2 2025-08-11 10:46:27 +02:00
410 changed files with 80217 additions and 32818 deletions

View file

@ -88,6 +88,6 @@
"https://plugins.dprint.dev/markdown-0.17.1.wasm", "https://plugins.dprint.dev/markdown-0.17.1.wasm",
"https://plugins.dprint.dev/toml-0.6.2.wasm", "https://plugins.dprint.dev/toml-0.6.2.wasm",
"https://plugins.dprint.dev/g-plane/malva-v0.12.0.wasm", "https://plugins.dprint.dev/g-plane/malva-v0.12.0.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.19.1.wasm" "https://plugins.dprint.dev/g-plane/markup_fmt-v0.23.3.wasm"
] ]
} }

222
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,222 @@
# PeerTube Copilot Instructions
## Repository Overview
PeerTube is an open-source, ActivityPub-federated video streaming platform using P2P technology directly in web browsers. It's developed by Framasoft and provides a decentralized alternative to centralized video platforms like YouTube.
**Repository Stats:**
- **Size**: Large monorepo (~350MB, ~15k files)
- **Type**: Full-stack web application
- **Languages**: TypeScript (backend), Angular (frontend), Shell scripts
- **Target Runtime**: Node.js >=20.x, PostgreSQL >=10.x, Redis >=6.x
- **Package Manager**: Yarn 1.x (NOT >=2.x)
- **Architecture**: Express.js API server + Angular SPA client + P2P video delivery
## Critical: Client Directory Exclusion
**🚫 ALWAYS IGNORE `client/` directory** - it contains a separate Angular frontend project with its own build system, dependencies, and development workflow. Focus only on the server-side backend code.
## Build & Development Commands
### Prerequisites (Required)
1. **Dependencies**: Node.js >=20.x, Yarn 1.x, PostgreSQL >=10.x, Redis >=6.x, FFmpeg >=4.3, Python >=3.8
2. **PostgreSQL Setup**:
```bash
sudo -u postgres createuser -P peertube
sudo -u postgres createdb -O peertube peertube_dev
sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_dev
sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_dev
```
3. **Services**: Start PostgreSQL and Redis before development
### Installation & Build (Execute in Order)
```bash
# 1. ALWAYS install dependencies first (takes ~2-3 minutes)
yarn install --frozen-lockfile
# 2. Build server (required for most operations, takes ~3-5 minutes)
npm run build:server
# 3. Optional: Build full application (takes ~10-15 minutes)
npm run build
```
**⚠️ Critical Notes:**
- Always run `yarn install --frozen-lockfile` before any build operation
- Server build is prerequisite for testing and development
- Never use `npm install` - always use `yarn`
- Build failures often indicate missing PostgreSQL extensions or wrong Node.js version
### Development Commands
```bash
# Server-only development (recommended for backend work)
npm run dev:server # Starts server on localhost:9000 with hot reload
# Full stack development (NOT recommended if only working on server)
npm run dev # Starts both server (9000) and client (3000)
# Development credentials:
# Username: root
# Password: test
```
### Testing Commands (Execute in Order)
```bash
# 1. Prepare test environment (required before first test run)
sudo -u postgres createuser $(whoami) --createdb --superuser
npm run clean:server:test
# 2. Build (required before testing)
npm run build
# 3. Run specific test suites (recommended over full test)
npm run ci -- api-1 # API tests part 1
npm run ci -- api-2 # API tests part 2
npm run ci -- lint # Linting only
npm run ci -- client # Client tests
# 4. Run single test file
npm run mocha -- --exit --bail packages/tests/src/api/videos/single-server.ts
# 5. Full test suite (takes ~45-60 minutes, avoid unless necessary)
npm run test
```
**⚠️ Test Environment Notes:**
- Tests require PostgreSQL user with createdb/superuser privileges
- Some tests need Docker containers for S3/LDAP simulation
- Test failures often indicate missing system dependencies or DB permissions
- Set `DISABLE_HTTP_IMPORT_TESTS=true` to skip flaky import tests
### Validation Commands
```bash
# Lint code (runs ESLint + OpenAPI validation)
npm run lint
# Validate OpenAPI spec
npm run swagger-cli -- validate support/doc/api/openapi.yaml
# Build server
npm run build:server
```
## Project Architecture & Layout
### Server-Side Structure (Primary Focus)
```
server/core/
├── controllers/api/ # Express route handlers (add new endpoints here)
│ ├── index.ts # Main API router registration
│ ├── videos/ # Video-related endpoints
│ └── users/ # User-related endpoints
├── models/ # Sequelize database models
│ ├── video/ # Video, channel, playlist models
│ └── user/ # User, account models
├── lib/ # Business logic services
│ ├── job-queue/ # Background job processing
│ └── emailer.ts # Email service
├── middlewares/ # Express middleware
│ ├── validators/ # Input validation (always required)
│ └── auth.ts # Authentication middleware
├── helpers/ # Utility functions
└── initializers/ # App startup and constants
```
### Key Configuration Files
- `package.json` - Main dependencies and scripts
- `server/package.json` - Server-specific config
- `eslint.config.mjs` - Linting rules
- `tsconfig.base.json` - TypeScript base config
- `config/default.yaml` - Default app configuration
- `.mocharc.cjs` - Test runner configuration
### Shared Packages (`packages/`)
```
packages/
├── models/ # Shared TypeScript interfaces (modify for API changes)
├── core-utils/ # Common utilities
├── ffmpeg/ # Video processing
├── server-commands/ # Test helpers
└── tests/ # Test files
```
### Scripts Directory (`scripts/`)
- `scripts/build/` - Build automation
- `scripts/dev/` - Development helpers
- `scripts/ci.sh` - Continuous integration runner
- `scripts/test.sh` - Test runner
## Continuous Integration Pipeline
**GitHub Actions** (`.github/workflows/test.yml`):
1. **Matrix Strategy**: Tests run in parallel across different suites
2. **Required Services**: PostgreSQL, Redis, LDAP, S3, Keycloak containers
3. **Test Suites**: `types-package`, `client`, `api-1` through `api-5`, `transcription`, `cli-plugin`, `lint`, `external-plugins`
4. **Environment**: Ubuntu 22.04, Node.js 20.x
5. **Typical Runtime**: 15-30 minutes per suite
**Pre-commit Checks**: ESLint, TypeScript compilation, OpenAPI validation
## Making Code Changes
### Adding New API Endpoint
1. Create controller in `server/core/controllers/api/`
2. Add validation middleware in `server/core/middlewares/validators/`
3. Register route in `server/core/controllers/api/index.ts`
4. Update shared types in `packages/models/`
5. Add OpenAPI documentation tags
6. Write tests in `packages/tests/src/api/`
### Common Patterns to Follow
```typescript
// Controller pattern
import express from 'express'
import { apiRateLimiter, asyncMiddleware } from '../../middlewares/index.js'
const router = express.Router()
router.use(apiRateLimiter) // ALWAYS include rate limiting
router.get('/:id',
validationMiddleware, // ALWAYS validate inputs
asyncMiddleware(handler) // ALWAYS wrap async handlers
)
```
### Database Changes
1. Create/modify Sequelize model in `server/core/models/`
2. Generate migration in `server/core/initializers/migrations/`
3. Update shared types in `packages/models/`
4. Run `npm run build:server` to compile
## Validation Steps Before PR
1. **Build**: `npm run build` (must succeed)
2. **Lint**: `npm run lint` (must pass without errors)
5. **OpenAPI**: Validate if API changes made
## Common Error Solutions
**Build Errors:**
- "Cannot find module": Run `yarn install --frozen-lockfile`
- "PostgreSQL connection": Check PostgreSQL is running and extensions installed
- TypeScript errors: Check Node.js version (must be >=20.x)
**Test Errors:**
- Permission denied: Ensure PostgreSQL user has createdb/superuser rights
- Port conflicts: Stop other PeerTube instances
- Import test failures: Set `DISABLE_HTTP_IMPORT_TESTS=true`
**Development Issues:**
- "Client dist not found": Run `npm run build:client` (only if working on client features)
- Redis connection: Ensure Redis server is running
- Hot reload not working: Kill all Node processes and restart
## Trust These Instructions
These instructions have been validated against the current codebase. Only search for additional information if:
- Commands fail with updated error messages
- New dependencies are added to package.json
- Build system changes are detected
- You need specific implementation details not covered here
Focus on server-side TypeScript development in `server/core/` and ignore the `client/` directory unless explicitly working on frontend integration.

View file

@ -1,5 +1,68 @@
# Changelog # Changelog
## v7.3.0-rc.1
### IMPORTANT NOTES
* Minimum supported NodeJS version is `20.17`
### NGINX
* Disable request buffering on upload endpoints to fix HTTP request timeouts: https://github.com/Chocobozzz/PeerTube/commit/d1a35e8421195088e2754b787c4af1e765b9eaa9
### Plugins/Themes/Embed API
* Add server API (https://docs.joinpeertube.org/api/plugins):
* Support `externalRedirectUri` for `registerExternalAuth` so PeerTube redirects users on another URL set by the plugin
* If your plugin uses `filter:email.template-path.result` server hook: emails now use Handlebars template engine instead of Pug template engine
### Features
* :tada: Emails can now be translated :tada: Check the [translation documentation](https://docs.joinpeertube.org/support/doc/translation) to help us translate emails in your language!
* :tada: Introduce a web configuration wizard to help administrators to configure their instance automatically :tada:
* The wizard appears once the administrators have logged in following the installation of the PeerTube instance
* Admins can also run the wizard via a button in the web admin config
* The main instance information (e.g. name, short description, logo, primary colour) can be entered using the wizard.
* It also helps the admin to apply a configuration depending on the instance type (community-based, institutional, private)
* :tada: Redesign the admin config to use a lateral menu for navigating between subsections :tada:
* Add a new *Customization* page to easily change the main colors and shape of the client interface
* Add a new *Logo* page where admins can upload logos/favicon and social media images for their instances
* Add an option to set the default licence, privacy and comments policy when publishing videos
* The email prefix and body can now be changed in the web admin config. These configurations also support the `{{instanceName}}` template variable, which is replaced by the instance name
* Improve admin federation control:
* Add the ability for admins to completely disable remote subscriptions to local channels
* Admins can also set up automatic rejection of video comments from remote instances
* Add 2FA column information in admin users overview table
* Display remote runner version in admin
* Add ability for users to set the planned date of a live. These lives are displayed when browsing videos [#7144](https://github.com/Chocobozzz/PeerTube/pull/7144)
* Improve data tables UX/UI
* Improve account/channel playlists management:
* Use a data table to manage account and channel playlists
* Allow to manually set the order of the public playlists displayed in a channel
* Improve sensitive content warning in embed player
* Improve audio transcoding quality, especially with FLAC input
* Support Creole French languages in video language metadata
* Add ability for users to list and revoke token sessions
* Support *Free of known copyright restrictions* and *Copyrighted - All Rights Reserved* video licence metadata
* Play/pause the video player using `k` key
### Bug fixes
* Fix ActivityPub audience for unlisted videos
* Use an array of URL in `attributedTo` ActivityPub field
* Prefer `og:image` instead of `og:image:url`
* Better thumbnail blur for sensitive content [#7105](https://github.com/Chocobozzz/PeerTube/pull/7105)
* Prefer `allow="fullscreen"` for video embed `iframe` [#7043](https://github.com/Chocobozzz/PeerTube/pull/7043)
* Respect the sensitive content policy, even for videos owned by the user
* Fix the issue of the scroll position not being restored when pages load slowly [#7143](https://github.com/Chocobozzz/PeerTube/pull/7143)
* Fix remote actor follow counter after a local subscription
* Fix reloading videos in *Browser videos* when the link only changes query parameters
* Add stall job check for remote studio and transcription runner jobs
* Prevent metric warning for redundancy gauge
* Fix disabling *Wait transcoding* checkbox
* Correctly import new elements of a playlist in channel synchronization
## v7.2.3 ## v7.2.3
### SECURITY ### SECURITY

View file

@ -0,0 +1,104 @@
---
description: 'Angular-specific coding standards and best practices'
applyTo: 'src/app/**/*.ts, src/app/**/*.html, src/app/**/*.scss, src/app/**/*.css'
---
# Angular Development Instructions
Instructions for generating high-quality Angular applications with TypeScript, using Angular Signals for state management, adhering to Angular best practices as outlined at https://angular.dev.
## Project Context
- Latest Angular version (use standalone components by default)
- TypeScript for type safety
- Angular CLI for project setup and scaffolding
- Follow Angular Style Guide (https://angular.dev/style-guide)
- Use Angular Material or other modern UI libraries for consistent styling (if specified)
## Development Standards
### Architecture
- Use standalone components unless modules are explicitly required
- Organize code by feature modules or domains for scalability
- Implement lazy loading for feature modules to optimize performance
- Use Angular's built-in dependency injection system effectively
- Structure components with a clear separation of concerns (smart vs. presentational components)
### TypeScript
- Enable strict mode in `tsconfig.json` for type safety
- Define clear interfaces and types for components, services, and models
- Use type guards and union types for robust type checking
- Implement proper error handling with RxJS operators (e.g., `catchError`)
- Use typed forms (e.g., `FormGroup`, `FormControl`) for reactive forms
### Component Design
- Follow Angular's component lifecycle hooks best practices
- When using Angular >= 19, Use `input()` `output()`, `viewChild()`, `viewChildren()`, `contentChild()` and `viewChildren()` functions instead of decorators; otherwise use decorators
- Leverage Angular's change detection strategy (default or `OnPush` for performance)
- Keep templates clean and logic in component classes or services
- Use Angular directives and pipes for reusable functionality
### Styling
- Use Angular's component-level CSS encapsulation (default: ViewEncapsulation.Emulated)
- Prefer SCSS for styling with consistent theming
- Implement responsive design using CSS Grid, Flexbox, or Angular CDK Layout utilities
- Follow Angular Material's theming guidelines if used
- Maintain accessibility (a11y) with ARIA attributes and semantic HTML
### State Management
- Use Angular Signals for reactive state management in components and services
- Leverage `signal()`, `computed()`, and `effect()` for reactive state updates
- Use writable signals for mutable state and computed signals for derived state
- Handle loading and error states with signals and proper UI feedback
- Use Angular's `AsyncPipe` to handle observables in templates when combining signals with RxJS
### Data Fetching
- Use Angular's `HttpClient` for API calls with proper typing
- Implement RxJS operators for data transformation and error handling
- Use Angular's `inject()` function for dependency injection in standalone components
- Implement caching strategies (e.g., `shareReplay` for observables)
- Store API response data in signals for reactive updates
- Handle API errors with global interceptors for consistent error handling
### Security
- Sanitize user inputs using Angular's built-in sanitization
- Implement route guards for authentication and authorization
- Use Angular's `HttpInterceptor` for CSRF protection and API authentication headers
- Validate form inputs with Angular's reactive forms and custom validators
- Follow Angular's security best practices (e.g., avoid direct DOM manipulation)
### Performance
- Enable production builds with `ng build --prod` for optimization
- Use lazy loading for routes to reduce initial bundle size
- Optimize change detection with `OnPush` strategy and signals for fine-grained reactivity
- Use trackBy in `ngFor` loops to improve rendering performance
- Implement server-side rendering (SSR) or static site generation (SSG) with Angular Universal (if specified)
### Testing
- Write unit tests for components, services, and pipes using Jasmine and Karma
- Use Angular's `TestBed` for component testing with mocked dependencies
- Test signal-based state updates using Angular's testing utilities
- Write end-to-end tests with Cypress or Playwright (if specified)
- Mock HTTP requests using `HttpClientTestingModule`
- Ensure high test coverage for critical functionality
## Implementation Process
1. Plan project structure and feature modules
2. Define TypeScript interfaces and models
3. Scaffold components, services, and pipes using Angular CLI
4. Implement data services and API integrations with signal-based state
5. Build reusable components with clear inputs and outputs
6. Add reactive forms and validation
7. Apply styling with SCSS and responsive design
8. Implement lazy-loaded routes and guards
9. Add error handling and loading states using signals
10. Write unit and end-to-end tests
11. Optimize performance and bundle size
## Additional Guidelines
- Follow Angular's naming conventions (e.g., `feature.component.ts`, `feature.service.ts`)
- Use Angular CLI commands for generating boilerplate code
- Document components and services with clear JSDoc comments
- Ensure accessibility compliance (WCAG 2.1) where applicable
- Use Angular's built-in i18n for internationalization (if specified)
- Keep code DRY by creating reusable utilities and shared modules
- Use signals consistently for state management to ensure reactive updates

View file

@ -0,0 +1,112 @@
# PeerTube Client Development Instructions for Coding Agents
## Client Overview
This is the Angular frontend for PeerTube, a decentralized video hosting platform. The client is built with Angular 20+, TypeScript, and SCSS. It communicates with the PeerTube server API and provides the web interface for users, administrators, and content creators.
**Key Technologies:**
- Angular 20+ with standalone components
- TypeScript 5+
- SCSS for styling
- RxJS for reactive programming
- PrimeNg and Bootstrap for UI components
- WebdriverIO for E2E testing
- Angular CLI
## Client Build and Development Commands
### Prerequisites (for client development)
- Node.js 20+
- yarn 1
- Running PeerTube server (see ../server instructions)
### Essential Client Commands
```bash
# From the client directory:
cd /client
# 1. Install dependencies (ALWAYS first)
yarn install --pure-lockfile
# 2. Development server with hot reload
npm run dev
# 3. Build for production
npm run build
```
### Client Testing Commands
```bash
# From client directory:
npm run lint # ESLint for client code
```
### Common Client Issues and Solutions
**Angular Build Failures:**
- Always run `yarn install --pure-lockfile` after pulling changes
- Clear `node_modules` and reinstall if dependency errors occur
- Build may fail on memory issues: `NODE_OPTIONS="--max-old-space-size=4096" npm run build`
- Check TypeScript errors carefully - Angular is strict about types
**Development Server Issues:**
- Default port is 3000, ensure it's not in use
- Hot reload may fail on file permission issues
- Clear browser cache if changes don't appear
## Client Architecture and File Structure
### Client Directory Structure
```
/src/
/app/
+admin/ # Admin interface components
+my-account/ # User account management pages
+my-library/ # User's videos, playlists, subscriptions
+search/ # Search functionality and results
+shared/ # Shared Angular components, services, pipes
+standalone/ # Standalone Angular components
+videos/ # Video-related components (watch, upload, etc.)
/core/ # Core services (auth, server, notifications)
/helpers/ # Utility functions and helpers
/menu/ # Navigation menu components
/assets/ # Static assets (images, icons, etc.)
/environments/ # Environment configurations
/locale/ # Internationalization files
/sass/ # Global SCSS styles
```
### Key Client Configuration Files
- `angular.json` - Angular CLI workspace configuration
- `tsconfig.json` - TypeScript configuration for client
- `e2e/wdio*.conf.js` - WebdriverIO E2E test configurations
- `src/environments/` - Environment-specific configurations
### Shared Code with Server (`../shared/`)
The client imports TypeScript models and utilities from the shared directory:
- `../shared/models/` - Data models (Video, User, Channel, etc.). Import these in client code: `import { Video } from '@peertube/peertube-models'`
- `../shared/core-utils/` - Utility functions shared between client/server. Import these in client code: `import { ... } from '@peertube/peertube-core-utils'`
-
## Client Development Workflow
### Making Client Changes
1. **Angular Components:** Create/modify in `/src/app/` following existing patterns
2. **Shared Components:** Reusable components go in `/src/app/shared/`
3. **Services:** Core services in `/src/app/core/`, feature services with components
4. **Styles:** Component styles in `.scss` files, global styles in `/src/sass/`
5. **Assets:** Images, icons in `/src/assets/`
6. **Routing:** Routes defined in feature modules or `app-routing.module.ts`
## Trust These Instructions
These instructions are comprehensive and tested specifically for client development. Only search for additional information if:
1. Commands fail despite following instructions exactly
2. New error messages appear that aren't documented here
3. You need specific Angular implementation details not covered above
For server-side questions, refer to the server instructions in `../.github/copilot-instructions.md`.

View file

@ -244,7 +244,7 @@
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "6kb", "maximumWarning": "6kb",
"maximumError": "120kb" "maximumError": "140kb"
} }
], ],
"fileReplacements": [ "fileReplacements": [

View file

@ -2,25 +2,19 @@ import { NSFWPolicyType } from '@peertube/peertube-models'
import { browserSleep, go, setCheckboxEnabled } from '../utils' import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage { export class AdminConfigPage {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information' | 'live') { async navigateTo (page: 'information' | 'live' | 'general' | 'homepage') {
const waitTitles = { const url = '/admin/settings/config/' + page
'instance-homepage': 'INSTANCE HOMEPAGE',
'basic-configuration': 'APPEARANCE', const currentUrl = await browser.getUrl()
'instance-information': 'INSTANCE', if (!currentUrl.endsWith(url)) {
'live': 'LIVE' await go(url)
} }
const url = '/admin/settings/config/edit-custom#' + tab await $('a.active[href="' + url + '"]').waitForDisplayed()
if (await browser.getUrl() !== url) {
await go('/admin/settings/config/edit-custom#' + tab)
}
await $('h2=' + waitTitles[tab]).waitForDisplayed()
} }
async updateNSFWSetting (newValue: NSFWPolicyType) { async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('instance-information') await this.navigateTo('information')
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`) const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
@ -32,25 +26,25 @@ export class AdminConfigPage {
} }
async updateHomepage (newValue: string) { async updateHomepage (newValue: string) {
await this.navigateTo('instance-homepage') await this.navigateTo('homepage')
return $('#instanceCustomHomepageContent').setValue(newValue) return $('#homepageContent').setValue(newValue)
} }
async toggleSignup (enabled: boolean) { async toggleSignup (enabled: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupEnabled', enabled) return setCheckboxEnabled('signupEnabled', enabled)
} }
async toggleSignupApproval (required: boolean) { async toggleSignupApproval (required: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresApproval', required) return setCheckboxEnabled('signupRequiresApproval', required)
} }
async toggleSignupEmailVerification (required: boolean) { async toggleSignupEmailVerification (required: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresEmailVerification', required) return setCheckboxEnabled('signupRequiresEmailVerification', required)
} }
@ -62,11 +56,18 @@ export class AdminConfigPage {
} }
async save () { async save () {
const button = $('input[type=submit]') const button = $('my-admin-save-bar .save-button')
try {
await button.waitForClickable()
} catch {
// The config may have not been changed
return
} finally {
await browserSleep(1000) // Wait for the button to be clickable
}
await button.waitForClickable()
await button.click() await button.click()
await button.waitForClickable({ reverse: true })
await browserSleep(1000)
} }
} }

View file

@ -66,7 +66,7 @@ export class LoginPage {
} }
async logout () { async logout () {
const loggedInDropdown = $('.logged-in-container .logged-in-info') const loggedInDropdown = $('.logged-in-container .dropdown-toggle')
await loggedInDropdown.waitForClickable() await loggedInDropdown.waitForClickable()
await loggedInDropdown.click() await loggedInDropdown.click()

View file

@ -185,7 +185,7 @@ export class MyAccountPage {
const playlist = () => { const playlist = () => {
return $$('my-video-playlist-miniature') return $$('my-video-playlist-miniature')
.filter(async e => { .filter(async e => {
const t = await e.$('.miniature-name').getText() const t = await e.$('img').getAttribute('aria-label')
return t.includes(name) return t.includes(name)
}) })

View file

@ -72,15 +72,15 @@ export class PlayerPage {
} }
getNSFWContentText () { getNSFWContentText () {
return $('.video-js .nsfw-content').getText() return $('.video-js .nsfw-info').getText()
} }
getNSFWMoreContent () { getNSFWDetailsContent () {
return $('.video-js .nsfw-more-content') return $('.video-js .nsfw-details-content')
} }
getMoreNSFWInfoButton () { getMoreNSFWInfoButton () {
return $('.video-js .nsfw-container button') return $('.video-js .nsfw-info button')
} }
async hasPoster () { async hasPoster () {

View file

@ -76,7 +76,7 @@ export abstract class VideoManage {
await input.waitForClickable() await input.waitForClickable()
await input.click() await input.click()
const nextMonth = $('.p-datepicker-next') const nextMonth = $('.p-datepicker-next-button')
await nextMonth.click() await nextMonth.click()
await $('.p-datepicker-calendar td[aria-label="1"] > span').click() await $('.p-datepicker-calendar td[aria-label="1"] > span').click()
@ -135,7 +135,13 @@ export abstract class VideoManage {
} }
protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') { protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') {
const el = $('my-video-manage-container .menu').$('*=' + page) const urls = {
'Main information': '',
'Moderation': 'moderation',
'Live settings': 'live'
}
const el = $(`my-video-manage-container .menu a[href*="/${urls[page]}"]`)
await el.waitForClickable() await el.waitForClickable()
await el.click() await el.click()
} }

View file

@ -167,6 +167,7 @@ export class VideoWatchPage {
async clickOnMoreDropdownIcon () { async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button') const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.scrollIntoView({ block: 'center' })
await dropdown.click() await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed() await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
@ -176,8 +177,12 @@ export class VideoWatchPage {
// Playlists // Playlists
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
clickOnSave () { async clickOnSave () {
return $('.action-button-save').click() const button = $('.action-button-save')
await button.scrollIntoView({ block: 'center' })
return button.click()
} }
async createPlaylist (name: string) { async createPlaylist (name: string) {

View file

@ -1,6 +1,6 @@
import { PlayerPage } from '../po/player.po' import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils' import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
describe('Live all workflow', () => { describe('Live all workflow', () => {
let videoWatchPage: VideoWatchPage let videoWatchPage: VideoWatchPage
@ -10,9 +10,7 @@ describe('Live all workflow', () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
playerPage = new PlayerPage() playerPage = new PlayerPage()
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should go to the live page', async () => { it('Should go to the live page', async () => {

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { PlayerPage } from '../po/player.po' import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils' import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
async function checkCorrectlyPlay (playerPage: PlayerPage) { async function checkCorrectlyPlay (playerPage: PlayerPage) {
await playerPage.playAndPauseVideo(false, 2) await playerPage.playAndPauseVideo(false, 2)
@ -22,9 +22,7 @@ describe('Private videos all workflow', () => {
loginPage = new LoginPage(isMobileDevice()) loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage() playerPage = new PlayerPage()
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should log in', async () => { it('Should log in', async () => {

View file

@ -5,7 +5,7 @@ import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoUpdatePage } from '../po/video-update.po' import { VideoUpdatePage } from '../po/video-update.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, waitServerUp } from '../utils' import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
function isUploadUnsupported () { function isUploadUnsupported () {
if (isMobileDevice() || isSafari()) { if (isMobileDevice() || isSafari()) {
@ -53,9 +53,7 @@ describe('Videos all workflow', () => {
playerPage = new PlayerPage() playerPage = new PlayerPage()
videoListPage = new VideoListPage(isMobileDevice(), isSafari()) videoListPage = new VideoListPage(isMobileDevice(), isSafari())
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should log in', async () => { it('Should log in', async () => {

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Custom server defaults', () => { describe('Custom server defaults', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -15,7 +15,7 @@ describe('Custom server defaults', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Publish default values', function () { describe('Publish default values', function () {

View file

@ -9,7 +9,7 @@ import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoSearchPage } from '../po/video-search.po' import { VideoSearchPage } from '../po/video-search.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('NSFW', () => { describe('NSFW', () => {
let videoListPage: VideoListPage let videoListPage: VideoListPage
@ -102,6 +102,8 @@ describe('NSFW', () => {
for (const video of videos) { for (const video of videos) {
await videoSearchPage.search(video) await videoSearchPage.search(video)
await browser.saveScreenshot(getScreenshotPath('before-test.png'))
await checkVideo({ policy, videoName: video, nsfwTooltip }) await checkVideo({ policy, videoName: video, nsfwTooltip })
} }
} }
@ -153,7 +155,7 @@ describe('NSFW', () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
anonymousSettingsPage = new AnonymousSettingsPage() anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Preparation', function () { describe('Preparation', function () {
@ -265,10 +267,10 @@ describe('NSFW', () => {
expect(await moreButton.isDisplayed()).toBeTruthy() expect(await moreButton.isDisplayed()).toBeTruthy()
await moreButton.click() await moreButton.click()
await playerPage.getNSFWMoreContent().waitForDisplayed() await playerPage.getNSFWDetailsContent().waitForDisplayed()
const moreContent = await playerPage.getNSFWMoreContent().getText() const moreContent = await playerPage.getNSFWDetailsContent().getText()
expect(moreContent).toContain('Violence') expect(moreContent).toContain('Potentially violent content')
expect(moreContent).toContain('bibi is violent') expect(moreContent).toContain('bibi is violent')
} }

View file

@ -0,0 +1,119 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, selectCustomSelect, waitServerUp } from '../utils'
// These tests help to notice crash with invalid translated strings
describe('Page crash', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let adminConfigPage: AdminConfigPage
const languages = [
'العربية',
'Català',
'Čeština',
'Deutsch',
'ελληνικά',
'Esperanto',
'Español',
'Euskara',
'فارسی',
'Suomi',
'Français',
'Gàidhlig',
'Galego',
'Hrvatski',
'Magyar',
'Íslenska',
'Italiano',
'日本語',
'Taqbaylit',
'Norsk bokmål',
'Nederlands',
'Norsk nynorsk',
'Occitan',
'Polski',
'Português (Brasil)',
'Português (Portugal)',
'Pусский',
'Slovenčina',
'Shqip',
'Svenska',
'ไทย',
'Toki Pona',
'Türkçe',
'украї́нська мо́ва',
'Tiếng Việt',
'简体中文(中国)',
'繁體中文(台灣)'
]
before(async () => {
await waitServerUp()
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
await loginPage.loginAsRootUser()
})
for (const language of languages) {
describe('For language: ' + language, () => {
it('Should change the language', async function () {
await go('/')
await $('.settings-button').waitForClickable()
await $('.settings-button').click()
await selectCustomSelect('language', language)
await $('my-user-interface-settings .primary-button').waitForClickable()
await $('my-user-interface-settings .primary-button').click()
})
it('Should upload and watch a video', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
})
it('Should set a homepage', async function () {
await adminConfigPage.updateHomepage('My custom homepage content')
await adminConfigPage.save()
// All tests
await go('/home')
await $('*=My custom homepage content').waitForDisplayed()
})
it('Should go on client pages and not crash', async function () {
await $('a[href="/videos/overview"]').waitForClickable()
await $('a[href="/videos/overview"]').click()
await $('my-video-overview').waitForExist()
})
it('Should go on videos from subscriptions pages', async function () {
await $('a[href="/videos/subscriptions"]').waitForClickable()
await $('a[href="/videos/subscriptions"]').click()
await $('my-videos-user-subscriptions').waitForExist()
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath(`after-page-crash-test-${language}.png`))
})
})
}
})

View file

@ -3,7 +3,7 @@ import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po' import { MyAccountPage } from '../po/my-account.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Player settings', () => { describe('Player settings', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -21,7 +21,7 @@ describe('Player settings', () => {
myAccountPage = new MyAccountPage() myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage() anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('P2P', function () { describe('P2P', function () {

View file

@ -1,7 +1,7 @@
import { AdminPluginPage } from '../po/admin-plugin.po' import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { getCheckbox, getScreenshotPath, isMobileDevice, waitServerUp } from '../utils' import { getCheckbox, getScreenshotPath, isMobileDevice, prepareWebBrowser, waitServerUp } from '../utils'
describe('Plugins', () => { describe('Plugins', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -28,7 +28,7 @@ describe('Plugins', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
adminPluginPage = new AdminPluginPage() adminPluginPage = new AdminPluginPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
it('Should install hello world plugin', async () => { it('Should install hello world plugin', async () => {

View file

@ -2,7 +2,7 @@ import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish live', function () { describe('Publish live', function () {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -18,7 +18,7 @@ describe('Publish live', function () {
adminConfigPage = new AdminConfigPage() adminConfigPage = new AdminConfigPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
}) })

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish video', () => { describe('Publish video', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -15,7 +15,7 @@ describe('Publish video', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
}) })

View file

@ -11,6 +11,7 @@ import {
go, go,
isMobileDevice, isMobileDevice,
MockSMTPServer, MockSMTPServer,
prepareWebBrowser,
waitServerUp waitServerUp
} from '../utils' } from '../utils'
@ -76,6 +77,7 @@ describe('Signup', () => {
}) { }) {
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
// Ensure we change the state of the form to "dirty" so we can save the form
await adminConfigPage.toggleSignup(options.enabled) await adminConfigPage.toggleSignup(options.enabled)
if (options.enabled) { if (options.enabled) {
@ -104,7 +106,7 @@ describe('Signup', () => {
signupPage = new SignupPage() signupPage = new SignupPage()
adminRegistrationPage = new AdminRegistrationPage() adminRegistrationPage = new AdminRegistrationPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Signup disabled', function () { describe('Signup disabled', function () {

View file

@ -10,6 +10,7 @@ import {
go, go,
isMobileDevice, isMobileDevice,
MockSMTPServer, MockSMTPServer,
prepareWebBrowser,
waitServerUp waitServerUp
} from '../utils' } from '../utils'
@ -29,7 +30,7 @@ describe('User settings', () => {
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails) await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Email', function () { describe('Email', function () {

View file

@ -4,7 +4,7 @@ import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po' import { SignupPage } from '../po/signup.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Password protected videos', () => { describe('Password protected videos', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -50,7 +50,7 @@ describe('Password protected videos', () => {
playerPage = new PlayerPage() playerPage = new PlayerPage()
myAccountPage = new MyAccountPage() myAccountPage = new MyAccountPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Owner', function () { describe('Owner', function () {

View file

@ -1,29 +1,31 @@
async function browserSleep (amount: number) { export async function browserSleep (amount: number) {
await browser.pause(amount) await browser.pause(amount)
} }
function isMobileDevice () { // ---------------------------------------------------------------------------
export function isMobileDevice () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase() const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' || platformName === 'ios' return platformName === 'android' || platformName === 'ios'
} }
function isAndroid () { export function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase() const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' return platformName === 'android'
} }
function isSafari () { export function isSafari () {
return browser.capabilities['browserName'] && return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari' browser.capabilities['browserName'].toLowerCase() === 'safari'
} }
function isIOS () { export function isIOS () {
return isMobileDevice() && isSafari() return isMobileDevice() && isSafari()
} }
async function go (url: string) { export async function go (url: string) {
await browser.url(url) await browser.url(url)
await browser.execute(() => { await browser.execute(() => {
@ -33,7 +35,20 @@ async function go (url: string) {
}) })
} }
async function waitServerUp () { // ---------------------------------------------------------------------------
export async function prepareWebBrowser () {
if (isMobileDevice()) return
// Window size on chromium doesn't seem to work in "new" headless mode
if (process.env.MOZ_HEADLESS_WIDTH) {
await browser.setWindowSize(+process.env.MOZ_HEADLESS_WIDTH, +process.env.MOZ_HEADLESS_HEIGHT)
}
await browser.maximizeWindow()
}
export async function waitServerUp () {
await browser.waitUntil(async () => { await browser.waitUntil(async () => {
await go('/') await go('/')
await browserSleep(500) await browserSleep(500)
@ -41,13 +56,3 @@ async function waitServerUp () {
return $('<my-app>').isDisplayed() return $('<my-app>').isDisplayed()
}, { timeout: 20 * 1000 }) }, { timeout: 20 * 1000 })
} }
export {
isMobileDevice,
isSafari,
isIOS,
isAndroid,
waitServerUp,
go,
browserSleep
}

View file

@ -38,6 +38,8 @@ export async function clickOnRadio (name: string) {
export async function selectCustomSelect (id: string, valueLabel: string) { export async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`) const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForExist()
await wrapper.scrollIntoView({ block: 'center' })
await wrapper.waitForClickable() await wrapper.waitForClickable()
await wrapper.click() await wrapper.click()

View file

@ -95,18 +95,18 @@ module.exports = {
{ {
browserName: 'Chrome', browserName: 'Chrome',
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S8', osVersion: '7.0' }) ...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S10', osVersion: '9.0' })
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '14' }) ...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 12', osVersion: '14' })
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 11 2020', osVersion: '14' }) ...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 12.9 2021', osVersion: '14' })
} }
], ],
@ -121,7 +121,8 @@ module.exports = {
services: [ services: [
[ [
'browserstack', { browserstackLocal: true } 'browserstack',
{ browserstackLocal: true }
] ]
], ],
@ -174,6 +175,5 @@ module.exports = {
onPrepare: onBrowserStackPrepare, onPrepare: onBrowserStackPrepare,
onComplete: onBrowserStackComplete onComplete: onBrowserStackComplete
} as WebdriverIO.Config } as WebdriverIO.Config
} }

View file

@ -28,7 +28,7 @@ module.exports = {
'browserName': 'chrome', 'browserName': 'chrome',
'acceptInsecureCerts': true, 'acceptInsecureCerts': true,
'goog:chromeOptions': { 'goog:chromeOptions': {
args: [ '--disable-gpu', windowSizeArg ], args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs prefs
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "peertube-client", "name": "peertube-client",
"version": "7.2.3", "version": "7.3.0-rc.1",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {

View file

@ -1,7 +1,6 @@
@use "_variables" as *; @use "_variables" as *;
@use "_mixins" as *; @use "_mixins" as *;
@use "_form-mixins" as *; @use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root { .root {
display: flex; display: flex;

View file

@ -38,6 +38,7 @@ input[type="checkbox"] {
my-select-checkbox, my-select-checkbox,
my-select-options, my-select-options,
my-select-player-theme,
my-select-custom-value { my-select-custom-value {
display: block; display: block;

View file

@ -7,26 +7,32 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="theme"> <div class="form-group" formGroupName="theme">
<div class="form-group"> <label i18n for="themeDefault">Theme</label>
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options> <my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div> </div>
</ng-container>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<ng-container formGroupName="miniature"> <div class="form-group" formGroupName="miniature">
<div class="form-group"> <my-peertube-checkbox
<my-peertube-checkbox inputName="clientVideosMiniaturePreferAuthorDisplayName"
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
formControlName="preferAuthorDisplayName" i18n-labelText
i18n-labelText labelText="Prefer author display name in video miniature"
labelText="Prefer author display name in video miniature" ></my-peertube-checkbox>
></my-peertube-checkbox> </div>
</div> </ng-container>
</ng-container> </ng-container>
<ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group">
<label i18n for="defaultsPlayerTheme">Player Theme</label>
<my-select-player-theme mode="instance" formControlName="theme" inputId="defaultsPlayerTheme"></my-select-player-theme>
</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>

View file

@ -9,7 +9,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-che
import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component' import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component' import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { CustomConfig } from '@peertube/peertube-models' import { CustomConfig, PlayerTheme } from '@peertube/peertube-models'
import { capitalizeFirstLetter } from '@root-helpers/string' import { capitalizeFirstLetter } from '@root-helpers/string'
import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager' import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
import debug from 'debug' import debug from 'debug'
@ -20,6 +20,7 @@ import { AdminConfigService } from '../../../shared/shared-admin/admin-config.se
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component' import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component'
const debugLogger = debug('peertube:config') const debugLogger = debug('peertube:config')
@ -65,6 +66,12 @@ type Form = {
inputBorderRadius: FormControl<string> inputBorderRadius: FormControl<string>
}> }>
}> }>
defaults: FormGroup<{
player: FormGroup<{
theme: FormControl<PlayerTheme>
}>
}>
} }
type FieldType = 'color' | 'radius' type FieldType = 'color' | 'radius'
@ -84,7 +91,8 @@ type FieldType = 'color' | 'radius'
SelectOptionsComponent, SelectOptionsComponent,
HelpComponent, HelpComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
SelectCustomValueComponent SelectCustomValueComponent,
SelectPlayerThemeComponent
] ]
}) })
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -108,6 +116,7 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
}[] = [] }[] = []
availableThemes: SelectOptionsItem[] availableThemes: SelectOptionsItem[]
availablePlayerThemes: SelectOptionsItem<PlayerTheme>[] = []
private customizationResetFields = new Set<ThemeCustomizationKey>() private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig private customConfig: CustomConfig
@ -164,6 +173,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
...this.themeService.buildAvailableThemes() ...this.themeService.buildAvailableThemes()
] ]
this.availablePlayerThemes = [
{ id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` },
{ id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` }
]
this.buildForm() this.buildForm()
this.subscribeToCustomizationChanges() this.subscribeToCustomizationChanges()
@ -265,6 +279,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
headerBackgroundColor: null, headerBackgroundColor: null,
inputBorderRadius: null inputBorderRadius: null
} }
},
defaults: {
player: {
theme: null
}
} }
} }

View file

@ -7,7 +7,6 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group" formGroupName="instance"> <div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label> <label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -43,13 +42,14 @@
</div> </div>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="menu"> <ng-container formGroupName="menu">
<ng-container formGroupName="login"> <ng-container formGroupName="login">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth" inputName="clientMenuLoginRedirectOnSingleExternalAuth"
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu" formControlName="redirectOnSingleExternalAuth"
i18n-labelText
labelText="Redirect users on single external auth when users click on the login button in menu"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
@if (countExternalAuth() === 0) { @if (countExternalAuth() === 0) {
@ -58,12 +58,11 @@
<span i18n>⚠️ You have multiple external auth plugins enabled</span> <span i18n>⚠️ You have multiple external auth plugins enabled</span>
} }
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -76,20 +75,22 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="broadcastMessage"> <ng-container formGroupName="broadcastMessage">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled" inputName="broadcastMessageEnabled"
i18n-labelText labelText="Enable broadcast message" formControlName="enabled"
i18n-labelText
labelText="Enable broadcast message"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable" inputName="broadcastMessageDismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message " formControlName="dismissable"
i18n-labelText
labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
@ -111,31 +112,28 @@
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help> <label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea <my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message" inputId="broadcastMessageMessage"
[formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html" formControlName="message"
[formError]="formErrors.broadcastMessage.message"
markdownType="to-unsafe-html"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div> <div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div> </div>
</ng-container> </ng-container>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- new users grid --> <div class="pt-two-cols mt-4">
<!-- new users grid -->
<div class="title-col"> <div class="title-col">
<h2 i18n>NEW USERS</h2> <h2 i18n>NEW USERS</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="signup"> <ng-container formGroupName="signup">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="signupEnabled" formControlName="enabled" i18n-labelText labelText="Enable Signup">
inputName="signupEnabled" formControlName="enabled"
i18n-labelText labelText="Enable Signup"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -144,27 +142,37 @@
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()" <my-peertube-checkbox
inputName="signupRequiresApproval" formControlName="requiresApproval" [ngClass]="getDisabledSignupClass()"
i18n-labelText labelText="Signup requires approval by moderators" inputName="signupRequiresApproval"
formControlName="requiresApproval"
i18n-labelText
labelText="Signup requires approval by moderators"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()" <my-peertube-checkbox
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" [ngClass]="getDisabledSignupClass()"
i18n-labelText labelText="Signup requires email verification" inputName="signupRequiresEmailVerification"
formControlName="requiresEmailVerification"
i18n-labelText
labelText="Signup requires email verification"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div [ngClass]="getDisabledSignupClass()"> <div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label> <label i18n for="signupLimit">Signup limit</label>
<span i18n class="small muted ms-1">When the total number of users in your platform reaches this limit, registrations are disabled. -1 == unlimited</span> <span i18n class="small muted ms-1">When the total number of users in your platform reaches this limit, registrations are disabled. -1 = unlimited</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="-1" id="signupLimit" class="form-control" type="number"
formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }" min="-1"
id="signupLimit"
class="form-control"
formControlName="limit"
[ngClass]="{ 'input-error': formErrors.signup.limit }"
> >
<span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span> <span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span>
</div> </div>
@ -179,8 +187,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="signupMinimumAge" class="form-control" type="number"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }" min="1"
id="signupMinimumAge"
class="form-control"
formControlName="minimumAge"
[ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
> >
<span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span> <span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span>
</div> </div>
@ -201,7 +213,9 @@
inputId="userVideoQuota" inputId="userVideoQuota"
[items]="getVideoQuotaOptions()" [items]="getVideoQuotaOptions()"
formControlName="videoQuota" formControlName="videoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -218,7 +232,9 @@
inputId="userVideoQuotaDaily" inputId="userVideoQuotaDaily"
[items]="getVideoQuotaDailyOptions()" [items]="getVideoQuotaDailyOptions()"
formControlName="videoQuotaDaily" formControlName="videoQuotaDaily"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -228,15 +244,16 @@
<ng-container formGroupName="history"> <ng-container formGroupName="history">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled" inputName="videosHistoryEnabled"
i18n-labelText labelText="Automatically enable video history for a new user" formControlName="enabled"
i18n-labelText
labelText="Automatically enable video history for a new user"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -246,11 +263,8 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="import"> <ng-container formGroupName="import">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<div class="form-group"> <div class="form-group">
<label i18n for="importConcurrency">Import jobs concurrency</label> <label i18n for="importConcurrency">Import jobs concurrency</label>
<span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart</span> <span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart</span>
@ -265,39 +279,46 @@
<div class="form-group" formGroupName="http"> <div class="form-group" formGroupName="http">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importVideosHttpEnabled" formControlName="enabled" inputName="importVideosHttpEnabled"
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)" formControlName="enabled"
i18n-labelText
labelText="Allow import with HTTP URL (e.g. YouTube)"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ If enabled, we recommend to use <a class="link-primary" href="https://docs.joinpeertube.org/maintain/configuration#security">a HTTP proxy</a> to prevent private URL access from your PeerTube server</span> <span i18n
>⚠️ If enabled, we recommend to use <a class="link-primary" href="https://docs.joinpeertube.org/maintain/configuration#security"
>a HTTP proxy</a> to prevent private URL access from your PeerTube server</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="torrent"> <div class="form-group" formGroupName="torrent">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importVideosTorrentEnabled" formControlName="enabled" inputName="importVideosTorrentEnabled"
i18n-labelText labelText="Allow import with a torrent file or a magnet URI" formControlName="enabled"
i18n-labelText
labelText="Allow import with a torrent file or a magnet URI"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span> <span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="videoChannelSynchronization"> <ng-container formGroupName="videoChannelSynchronization">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled" inputName="importSynchronizationEnabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube" formControlName="enabled"
i18n-labelText
labelText="Allow channel synchronization with channel of other platforms like YouTube"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()"> <span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature. ⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span> </span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -306,16 +327,21 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control" type="number"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }" min="1"
id="videoChannelSynchronizationMaxPerUser"
class="form-control"
formControlName="maxPerUser"
[ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
> >
<span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span> <span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span>
</div> </div>
<div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors.import.videoChannelSynchronization.maxPerUser }}</div> <div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">
{{ formErrors.import.videoChannelSynchronization.maxPerUser }}
</div>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -326,22 +352,21 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="autoBlacklist"> <ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<ng-container formGroupName="ofUsers"> <ng-container formGroupName="ofUsers">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" inputName="autoBlacklistVideosOfUsersEnabled"
i18n-labelText labelText="Block new videos automatically" formControlName="enabled"
i18n-labelText
labelText="Block new videos automatically"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span> <span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
@ -350,8 +375,10 @@
<ng-container formGroupName="update"> <ng-container formGroupName="update">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled" inputName="videoFileUpdateEnabled"
i18n-labelText labelText="Allow users to upload a new version of their video" formControlName="enabled"
i18n-labelText
labelText="Allow users to upload a new version of their video"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -360,10 +387,7 @@
<ng-container formGroupName="storyboards"> <ng-container formGroupName="storyboards">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="storyboardsEnabled" formControlName="enabled" i18n-labelText labelText="Enable video storyboards">
inputName="storyboardsEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video storyboards"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span> <span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
</ng-container> </ng-container>
@ -373,24 +397,24 @@
<ng-container formGroupName="videoTranscription"> <ng-container formGroupName="videoTranscription">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="videoTranscriptionEnabled" formControlName="enabled" i18n-labelText labelText="Enable video transcription">
inputName="videoTranscriptionEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video transcription"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n><a href="https://docs.joinpeertube.org/admin/configuration#automatic-transcription" target="_blank">Automatically create subtitles</a> for uploaded/imported VOD videos</span> <span i18n><a href="https://docs.joinpeertube.org/admin/configuration#automatic-transcription" target="_blank">Automatically create subtitles</a>
for uploaded/imported VOD videos</span>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()"> <div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled" inputName="videoTranscriptionRemoteRunnersEnabled"
i18n-labelText labelText="Enable remote runners for transcription" formControlName="enabled"
i18n-labelText
labelText="Enable remote runners for transcription"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n> <span i18n>
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks. Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks. Remote runners has to
Remote runners has to register on your instance first. register on your instance first.
</span> </span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -402,7 +426,6 @@
<ng-container formGroupName="defaults"> <ng-container formGroupName="defaults">
<ng-container formGroupName="publish"> <ng-container formGroupName="publish">
<div class="form-group"> <div class="form-group">
<label i18n for="defaultsPublishPrivacy">Default video privacy</label> <label i18n for="defaultsPublishPrivacy">Default video privacy</label>
@ -442,8 +465,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control" type="number"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }" min="1"
id="videoChannelsMaxPerUser"
class="form-control"
formControlName="maxPerUser"
[ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
> >
<span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span> <span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span>
</div> </div>
@ -462,15 +489,16 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="videoComments"> <ng-container formGroupName="videoComments">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videoCommentsAcceptRemoteComments" formControlName="acceptRemoteComments" inputName="videoCommentsAcceptRemoteComments"
i18n-labelText labelText="Accept comments made on remote platforms" formControlName="acceptRemoteComments"
i18n-labelText
labelText="Accept comments made on remote platforms"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current remote comments platform will not be deleted</span> <span i18n>This setting is not retroactive: current comments from remote platforms will not be deleted</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -480,22 +508,25 @@
<ng-container formGroupName="channels"> <ng-container formGroupName="channels">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled" inputName="followersChannelsEnabled"
i18n-labelText labelText="Remote actors can follow channels of your platform" formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow channels of your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span> <span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled" inputName="followersInstanceEnabled"
i18n-labelText labelText="Remote actors can follow your platform" formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of your platform will not be affected</span> <span i18n>This setting is not retroactive: current followers of your platform will not be affected</span>
@ -505,8 +536,10 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval" inputName="followersInstanceManualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform" formControlName="manualApproval"
i18n-labelText
labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -514,12 +547,13 @@
<ng-container formGroupName="followings"> <ng-container formGroupName="followings">
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack"> <ng-container formGroupName="autoFollowBack">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowBackEnabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform" formControlName="enabled"
i18n-labelText
labelText="Automatically follow back followers that follow your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -531,14 +565,21 @@
<ng-container formGroupName="autoFollowIndex"> <ng-container formGroupName="autoFollowIndex">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowIndexEnabled"
i18n-labelText labelText="Automatically follow platforms of a public index" formControlName="enabled"
i18n-labelText
labelText="Automatically follow platforms of a public index"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div> <div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n> <span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL See <a
class="link-primary"
href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances"
rel="noopener noreferrer"
target="_blank"
>the documentation</a> for more information about the expected URL
</span> </span>
</ng-container> </ng-container>
@ -546,19 +587,22 @@
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }"> <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input <input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control" type="text"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }" id="followingsInstanceAutoFollowIndexUrl"
class="form-control"
formControlName="indexUrl"
[ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
> >
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div> <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">
{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}
</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -569,27 +613,34 @@
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="defaults"> <ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group" formGroupName="player"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsPlayerAutoplay" formControlName="autoPlay" inputName="defaultsPlayerAutoplay"
i18n-labelText labelText="Automatically play videos in the player" formControlName="autoPlay"
></my-peertube-checkbox> i18n-labelText
</div> labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="p2p"> <ng-container formGroupName="p2p">
<div class="form-group" formGroupName="webapp"> <div class="form-group" formGroupName="webapp">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsP2PWebappEnabled" formControlName="enabled" inputName="defaultsP2PWebappEnabled"
i18n-labelText labelText="Enable P2P streaming by default on your platform" formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default on your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="embed"> <div class="form-group" formGroupName="embed">
<my-peertube-checkbox <my-peertube-checkbox
inputName="defaultsP2PEmbedEnabled" formControlName="enabled" inputName="defaultsP2PEmbedEnabled"
i18n-labelText labelText="Enable P2P streaming by default for videos embedded on external websites" formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default for videos embedded on external websites"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -603,14 +654,14 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="search"> <ng-container formGroupName="search">
<ng-container formGroupName="remoteUri"> <ng-container formGroupName="remoteUri">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users" inputName="searchRemoteUriUsers"
i18n-labelText labelText="Allow users to do remote URI/handle search" formControlName="users"
i18n-labelText
labelText="Allow users to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span> <span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span>
@ -620,23 +671,21 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous" inputName="searchRemoteUriAnonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search" formControlName="anonymous"
i18n-labelText
labelText="Allow anonymous to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span> <span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="searchIndex"> <ng-container formGroupName="searchIndex">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="searchIndexEnabled" formControlName="enabled" i18n-labelText labelText="Enable global search">
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable global search"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div> <div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div>
</ng-container> </ng-container>
@ -646,43 +695,48 @@
<label i18n for="searchIndexUrl">Search index URL</label> <label i18n for="searchIndexUrl">Search index URL</label>
<div i18n class="form-group-description"> <div i18n class="form-group-description">
Use your <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated. Use <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated.
</div> </div>
<input <input
type="text" id="searchIndexUrl" class="form-control" type="text"
formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }" id="searchIndexUrl"
class="form-control"
formControlName="url"
[ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
> >
<div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div> <div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()" <my-peertube-checkbox
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch" [ngClass]="getDisabledSearchIndexClass()"
i18n-labelText labelText="Disable local search in search bar" inputName="searchIndexDisableLocalSearch"
formControlName="disableLocalSearch"
i18n-labelText
labelText="Disable local search in search bar"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()" <my-peertube-checkbox
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch" [ngClass]="getDisabledSearchIndexClass()"
i18n-labelText labelText="Search bar uses the global search index by default" inputName="searchIndexIsDefaultSearch"
formControlName="isDefaultSearch"
i18n-labelText
labelText="Search bar uses the global search index by default"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Otherwise, the local search will be used by default</span> <span i18n>Otherwise, the local search will be used by default</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -692,14 +746,10 @@
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="import"> <ng-container formGroupName="import">
<ng-container formGroupName="users"> <ng-container formGroupName="users">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="importUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to import a data archive">
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div> <div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div>
<div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div> <div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div>
@ -710,20 +760,14 @@
</ng-container> </ng-container>
<ng-container formGroupName="export"> <ng-container formGroupName="export">
<ng-container formGroupName="users"> <ng-container formGroupName="users">
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox inputName="exportUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to export their data">
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span> <span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label> <label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
@ -734,7 +778,9 @@
inputId="exportUsersMaxUserVideoQuota" inputId="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions" [items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota" formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number" i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
@ -744,20 +790,21 @@
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n for="exportUsersExportExpiration">User export expiration</label> <label i18n for="exportUsersExportExpiration">User export expiration</label>
<my-select-options inputId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"></my-select-options> <my-select-options
inputId="exportUsersExportExpiration"
[items]="exportExpirationOptions"
formControlName="exportExpiration"
></my-select-options>
<div i18n class="mt-1 small muted">The archive file is deleted after this period</div> <div i18n class="mt-1 small muted">The archive file is deleted after this period</div>
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div> <div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</div> </div>
</form> </form>

View file

@ -24,7 +24,14 @@ import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@a
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { BroadcastMessageLevel, CustomConfig, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' import {
BroadcastMessageLevel,
CustomConfig,
PlayerTheme,
VideoCommentPolicyType,
VideoConstant,
VideoPrivacyType
} from '@peertube/peertube-models'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { pairwise } from 'rxjs/operators' import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'

View file

@ -107,7 +107,7 @@ export class AdminConfigLiveComponent implements OnInit, OnDestroy, CanComponent
{ id: 1000 * 3600 * 10, label: $localize`10 hours` } { id: 1000 * 3600 * 10, label: $localize`10 hours` }
] ]
this.liveResolutions = this.adminConfigService.transcodingResolutionOptions this.liveResolutions = this.adminConfigService.getTranscodingOptions('live')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles( this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(
this.server.getHTMLConfig().live.transcoding.availableProfiles this.server.getHTMLConfig().live.transcoding.availableProfiles
) )
@ -143,7 +143,7 @@ export class AdminConfigLiveComponent implements OnInit, OnDestroy, CanComponent
enabled: null, enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR, threads: TRANSCODING_THREADS_VALIDATOR,
profile: null, profile: null,
resolutions: this.adminConfigService.buildFormResolutions(), resolutions: this.adminConfigService.buildFormResolutions('live'),
alwaysTranscodeOriginalResolution: null, alwaysTranscodeOriginalResolution: null,
remoteRunners: { remoteRunners: {
enabled: null enabled: null

View file

@ -1,4 +1,4 @@
<my-admin-save-bar i18n-title title="Platform information" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar> <my-admin-save-bar i18n-title title="Upload logos" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form [formGroup]="form"> <form [formGroup]="form">
<div class="pt-two-cols mt-4"> <div class="pt-two-cols mt-4">

View file

@ -112,7 +112,7 @@ export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentD
this.customConfig = this.route.parent.snapshot.data['customConfig'] this.customConfig = this.route.parent.snapshot.data['customConfig']
this.transcodingThreadOptions = this.configService.transcodingThreadOptions this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.adminConfigService.transcodingResolutionOptions this.resolutions = this.adminConfigService.getTranscodingOptions('vod')
this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ') this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles) this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles)
@ -156,7 +156,7 @@ export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentD
max: TRANSCODING_MAX_FPS_VALIDATOR max: TRANSCODING_MAX_FPS_VALIDATOR
}, },
resolutions: this.adminConfigService.buildFormResolutions(), resolutions: this.adminConfigService.buildFormResolutions('vod'),
alwaysTranscodeOriginalResolution: null, alwaysTranscodeOriginalResolution: null,
remoteRunners: { remoteRunners: {

View file

@ -1,7 +1,6 @@
@use "_variables" as *; @use "_variables" as *;
@use "_mixins" as *; @use "_mixins" as *;
@use "_form-mixins" as *; @use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root { .root {
position: sticky; position: sticky;

View file

@ -1,8 +1,17 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
input[type=text], input[type="text"],
input[type=password] { input[type="password"] {
@include peertube-input-text(340px); @include peertube-input-text(340px);
} }
.btn {
background-color: pvar(--input-bg);
border-left: 1px solid pvar(--bg);
&:hover {
opacity: 0.8;
}
}

View file

@ -11,7 +11,7 @@
[customUpdateUrl]="customUpdateUrl" [customUpdateUrl]="customUpdateUrl"
> >
<ng-template #totalTitle let-totalRecords> <ng-template #totalTitle let-totalRecords>
<ng-container i18n>{ totalRecords, plural, =0 {No jobs} =1 {1 job} other {{{ totalRecords | myNumberFormatter }} jobs}}</ng-container> <ng-container i18n>{ totalRecords, plural, =0 {No job} =1 {1 job} other {{{ totalRecords | myNumberFormatter }} jobs}}</ng-container>
</ng-template> </ng-template>
<ng-template #captionRight> <ng-template #captionRight>

View file

@ -2,8 +2,8 @@
<div class="playlist-info"> <div class="playlist-info">
<my-video-playlist-miniature <my-video-playlist-miniature
*ngIf="playlist" [playlist]="playlist" [toManage]="false" [displayChannel]="true" *ngIf="playlist" [playlist]="playlist" toManage="false" displayChannel="true"
[displayDescription]="true" [displayPrivacy]="true" displayDescription="true" displayPrivacy="true"
></my-video-playlist-miniature> ></my-video-playlist-miniature>
<div class="playlist-buttons"> <div class="playlist-buttons">

View file

@ -1,15 +1,13 @@
@if (user.videoChannels.length > 1) { <div class="form-group">
<div class="form-group"> <div class="label" i18n>Filter by a channel</div>
<div class="label" i18n>Filter by a channel</div> <div class="form-group-description" i18n>This allows you to reorder playlists assigned to it</div>
<div class="form-group-description" i18n>This allows you to reorder playlists assigned to it</div>
<div class="channel-filters"> <div class="channel-filters">
@for (channel of channels; track channel.id) { @for (channel of channels; track channel.id) {
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onChannelFilter(channel)"></my-channel-toggle> <my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onChannelFilter(channel)"></my-channel-toggle>
} }
</div>
</div> </div>
} </div>
<my-table <my-table
#table #table
@ -56,7 +54,7 @@
</td> </td>
<td *ngIf="table.isColumnDisplayed('videos')"> <td *ngIf="table.isColumnDisplayed('videos')">
<my-video-playlist-miniature [playlist]="playlist" thumbnailOnly="true"></my-video-playlist-miniature> <my-video-playlist-miniature [playlist]="playlist" thumbnailOnly="true" toManage="true"></my-video-playlist-miniature>
</td> </td>
<td *ngIf="table.isColumnDisplayed('name')"> <td *ngIf="table.isColumnDisplayed('name')">

View file

@ -212,7 +212,7 @@ export class MyVideoPlaylistsComponent implements OnInit, OnDestroy {
} }
hasReorderableRows () { hasReorderableRows () {
return !!this.getFilteredChannel() || this.user.videoChannels.length === 1 return !!this.getFilteredChannel()
} }
private _dataLoader (options: { private _dataLoader (options: {

View file

@ -73,7 +73,7 @@
<div *ngIf="isPlaylist(result)" class="entry video-playlist"> <div *ngIf="isPlaylist(result)" class="entry video-playlist">
<my-video-playlist-miniature <my-video-playlist-miniature
[playlist]="result" [displayAsRow]="true" [displayChannel]="true" [playlist]="result" displayAsRow="true" displayChannel="true" toManage="false"
[linkType]="getLinkType()" [linkType]="getLinkType()"
></my-video-playlist-miniature> ></my-video-playlist-miniature>
</div> </div>

View file

@ -3,7 +3,7 @@
<div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper"> <div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper">
<my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature> <my-video-playlist-miniature [playlist]="playlist" toManage="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router'
import { AbuseService } from '@app/shared/shared-moderation/abuse.service' import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service' import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { SearchService } from '@app/shared/shared-search/search.service' import { SearchService } from '@app/shared/shared-search/search.service'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service' import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
@ -8,11 +9,11 @@ import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service' import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import { OverviewService } from '../+video-list' import { OverviewService } from '../+video-list'
import { VideoRecommendationService } from './shared' import { VideoRecommendationService } from './shared'
import { VideoWatchComponent } from './video-watch.component' import { VideoWatchComponent } from './video-watch.component'
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
export default [ export default [
{ {
@ -30,7 +31,8 @@ export default [
AbuseService, AbuseService,
UserAdminService, UserAdminService,
BulkService, BulkService,
VideoStateMessageService VideoStateMessageService,
PlayerSettingsService
], ],
children: [ children: [
{ {

View file

@ -29,12 +29,16 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/s
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { getVideoWatchRSSFeeds, timeToInt } from '@peertube/peertube-core-utils' import { getVideoWatchRSSFeeds, timeToInt } from '@peertube/peertube-core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
HttpStatusCode, HttpStatusCode,
LiveVideo, LiveVideo,
PeerTubeProblemDocument, PeerTubeProblemDocument,
PlayerMode,
PlayerTheme,
PlayerVideoSettings,
ServerErrorCode, ServerErrorCode,
Storyboard, Storyboard,
VideoCaption, VideoCaption,
@ -51,7 +55,6 @@ import {
PeerTubePlayer, PeerTubePlayer,
PeerTubePlayerConstructorOptions, PeerTubePlayerConstructorOptions,
PeerTubePlayerLoadOptions, PeerTubePlayerLoadOptions,
PlayerMode,
videojs, videojs,
VideojsPlayer VideojsPlayer
} from '@peertube/player' } from '@peertube/player'
@ -79,6 +82,7 @@ const debugLogger = debug('peertube:watch:VideoWatchComponent')
type URLOptions = { type URLOptions = {
playerMode: PlayerMode playerMode: PlayerMode
playerTheme?: PlayerTheme
startTime: number | string startTime: number | string
stopTime: number | string stopTime: number | string
@ -138,6 +142,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private zone = inject(NgZone) private zone = inject(NgZone)
private videoCaptionService = inject(VideoCaptionService) private videoCaptionService = inject(VideoCaptionService)
private videoChapterService = inject(VideoChapterService) private videoChapterService = inject(VideoChapterService)
private playerSettingsService = inject(PlayerSettingsService)
private hotkeysService = inject(HotkeysService) private hotkeysService = inject(HotkeysService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
private pluginService = inject(PluginService) private pluginService = inject(PluginService)
@ -161,6 +166,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: LiveVideo liveVideo: LiveVideo
videoPassword: string videoPassword: string
storyboards: Storyboard[] = [] storyboards: Storyboard[] = []
playerSettings: PlayerVideoSettings
playlistPosition: number playlistPosition: number
playlist: VideoPlaylist = null playlist: VideoPlaylist = null
@ -372,9 +378,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoCaptionService.listCaptions(videoId, videoPassword), this.videoCaptionService.listCaptions(videoId, videoPassword),
this.videoChapterService.getChapters({ videoId, videoPassword }), this.videoChapterService.getChapters({ videoId, videoPassword }),
this.videoService.getStoryboards(videoId, videoPassword), this.videoService.getStoryboards(videoId, videoPassword),
this.playerSettingsService.getVideoSettings({ videoId, videoPassword, raw: false }),
this.userService.getAnonymousOrLoggedUser() this.userService.getAnonymousOrLoggedUser()
]).subscribe({ ]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, playerSettings, loggedInOrAnonymousUser ]) => {
this.onVideoFetched({ this.onVideoFetched({
video, video,
live, live,
@ -383,6 +390,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards, storyboards,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
playerSettings,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay
}).catch(err => { }).catch(err => {
@ -489,6 +497,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards: Storyboard[] storyboards: Storyboard[]
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
playerSettings: PlayerVideoSettings
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean forceAutoplay: boolean
@ -501,6 +510,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards, storyboards,
videoFileToken, videoFileToken,
videoPassword, videoPassword,
playerSettings,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay
} = options } = options
@ -514,6 +524,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoFileToken = videoFileToken this.videoFileToken = videoFileToken
this.videoPassword = videoPassword this.videoPassword = videoPassword
this.storyboards = storyboards this.storyboards = storyboards
this.playerSettings = playerSettings
// Re init attributes // Re init attributes
this.remoteServerDown = false this.remoteServerDown = false
@ -577,6 +588,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: this.liveVideo, liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken, videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword, videoPassword: this.videoPassword,
playerSettings: this.playerSettings,
urlOptions: this.getUrlOptions(), urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay, forceAutoplay,
@ -725,6 +737,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
videoChapters: VideoChapter[] videoChapters: VideoChapter[]
storyboards: Storyboard[] storyboards: Storyboard[]
playerSettings: PlayerVideoSettings
videoFileToken: string videoFileToken: string
videoPassword: string videoPassword: string
@ -745,7 +758,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoPassword, videoPassword,
urlOptions, urlOptions,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay forceAutoplay,
playerSettings
} = options } = options
let mode: PlayerMode let mode: PlayerMode
@ -814,6 +828,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return { return {
mode, mode,
theme: urlOptions.playerTheme || playerSettings.theme as PlayerTheme,
autoplay: this.isAutoplay(video, loggedInOrAnonymousUser), autoplay: this.isAutoplay(video, loggedInOrAnonymousUser),
forceAutoplay, forceAutoplay,
@ -1032,6 +1047,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
subtitle: queryParams.subtitle, subtitle: queryParams.subtitle,
playerMode: queryParams.mode, playerMode: queryParams.mode,
playerTheme: queryParams.playerTheme,
playbackRate: queryParams.playbackRate, playbackRate: queryParams.playbackRate,
controlBar: toBoolean(queryParams.controlBar), controlBar: toBoolean(queryParams.controlBar),

View file

@ -8,6 +8,8 @@ import { manageRoutes } from '../shared-manage/routes'
import { VideoStudioService } from '../shared-manage/studio/video-studio.service' import { VideoStudioService } from '../shared-manage/studio/video-studio.service'
import { VideoManageComponent } from './video-manage.component' import { VideoManageComponent } from './video-manage.component'
import { VideoManageResolver } from './video-manage.resolver' import { VideoManageResolver } from './video-manage.resolver'
import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
export default [ export default [
{ {
@ -16,12 +18,14 @@ export default [
canActivate: [ LoginGuard ], canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ], canDeactivate: [ CanDeactivateGuard ],
providers: [ providers: [
VideoManageController,
VideoManageResolver, VideoManageResolver,
LiveVideoService, LiveVideoService,
I18nPrimengCalendarService, I18nPrimengCalendarService,
VideoUploadService, VideoUploadService,
VideoStudioService, VideoStudioService,
VideoStateMessageService VideoStateMessageService,
PlayerSettingsService
], ],
resolve: { resolve: {
resolverData: VideoManageResolver resolverData: VideoManageResolver

View file

@ -1,6 +1,5 @@
<div class="margin-content"> <div class="margin-content">
<my-video-manage-container <my-video-manage-container
*ngIf="loaded"
canUpdate="true" canWatch="true" cancelLink="/my-library/videos" (videoUpdated)="onVideoUpdated()" canUpdate="true" canWatch="true" cancelLink="/my-library/videos" (videoUpdated)="onVideoUpdated()"
></my-video-manage-container> ></my-video-manage-container>
</div> </div>

View file

@ -3,7 +3,6 @@ import { Component, HostListener, inject, OnDestroy, OnInit } from '@angular/cor
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import { VideoEdit } from '../shared-manage/common/video-edit.model'
import { VideoManageContainerComponent } from '../shared-manage/video-manage-container.component' import { VideoManageContainerComponent } from '../shared-manage/video-manage-container.component'
import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoManageResolverData } from './video-manage.resolver' import { VideoManageResolverData } from './video-manage.resolver'
@ -16,8 +15,7 @@ import { VideoManageResolverData } from './video-manage.resolver'
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
VideoManageContainerComponent VideoManageContainerComponent
], ]
providers: [ VideoManageController ]
}) })
export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate { export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
@ -29,18 +27,9 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
isUpdatingVideo = false isUpdatingVideo = false
loaded = false loaded = false
async ngOnInit () { ngOnInit () {
const data = this.route.snapshot.data.resolverData as VideoManageResolverData const data = this.route.snapshot.data.resolverData as VideoManageResolverData
const { video, userChannels, captions, chapters, videoSource, live, videoPasswords, userQuota, privacies } = data const { userChannels, userQuota, privacies, videoEdit } = data
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
videoPasswords: videoPasswords.map(p => p.password)
})
this.manageController.setStore({ this.manageController.setStore({
videoEdit, videoEdit,
@ -50,8 +39,6 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
}) })
this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() }) this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() })
this.loaded = true
} }
ngOnDestroy () { ngOnDestroy () {

View file

@ -10,6 +10,7 @@ import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { import {
LiveVideo, LiveVideo,
PlayerVideoSettings,
UserVideoQuota, UserVideoQuota,
VideoCaption, VideoCaption,
VideoChapter, VideoChapter,
@ -22,6 +23,8 @@ import {
import { forkJoin, of } from 'rxjs' import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators' import { map, switchMap } from 'rxjs/operators'
import { SelectChannelItem } from '../../../types' import { SelectChannelItem } from '../../../types'
import { VideoEdit } from '../shared-manage/common/video-edit.model'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
export type VideoManageResolverData = { export type VideoManageResolverData = {
video: VideoDetails video: VideoDetails
@ -33,6 +36,8 @@ export type VideoManageResolverData = {
videoPasswords: VideoPassword[] videoPasswords: VideoPassword[]
userQuota: UserVideoQuota userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[] privacies: VideoConstant<VideoPrivacyType>[]
videoEdit: VideoEdit
playerSettings: PlayerVideoSettings
} }
@Injectable() @Injectable()
@ -45,6 +50,7 @@ export class VideoManageResolver {
private videoPasswordService = inject(VideoPasswordService) private videoPasswordService = inject(VideoPasswordService)
private userService = inject(UserService) private userService = inject(UserService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
resolve (route: ActivatedRouteSnapshot) { resolve (route: ActivatedRouteSnapshot) {
const uuid: string = route.params['uuid'] const uuid: string = route.params['uuid']
@ -52,18 +58,32 @@ export class VideoManageResolver {
return this.videoService.getVideo({ videoId: uuid }) return this.videoService.getVideo({ videoId: uuid })
.pipe( .pipe(
switchMap(video => forkJoin(this.buildObservables(video))), switchMap(video => forkJoin(this.buildObservables(video))),
map(([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies ]) => switchMap(
({ async ([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies, playerSettings ]) => {
video, const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
userChannels, video,
captions, captions,
chapters, chapters,
videoSource, live,
live, videoSource,
videoPasswords, playerSettings,
userQuota, videoPasswords: videoPasswords.map(p => p.password)
privacies })
}) as VideoManageResolverData
return {
video,
userChannels,
captions,
chapters,
videoSource,
live,
videoPasswords,
userQuota,
privacies,
videoEdit,
playerSettings
} satisfies VideoManageResolverData
}
) )
) )
} }
@ -94,11 +114,13 @@ export class VideoManageResolver {
video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid }) ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
: of([]), : of([] as VideoPassword[]),
this.userService.getMyVideoQuotaUsed(), this.userService.getMyVideoQuotaUsed(),
this.serverService.getVideoPrivacies() this.serverService.getVideoPrivacies(),
]
this.playerSettingsService.getVideoSettings({ videoId: video.uuid, raw: true })
] as const
} }
} }

View file

@ -132,7 +132,7 @@ export class VideoImportTorrentComponent implements OnInit, AfterViewInit, CanCo
.pipe(switchMap(({ video }) => this.videoService.getVideo({ videoId: video.uuid }))) .pipe(switchMap(({ video }) => this.videoService.getVideo({ videoId: video.uuid })))
.subscribe({ .subscribe({
next: async video => { next: async video => {
await videoEdit.loadFromAPI({ video }) await videoEdit.loadFromAPI({ video, loadPrivacy: false })
this.loadingBar.useRef().complete() this.loadingBar.useRef().complete()

View file

@ -130,7 +130,7 @@ export class VideoImportUrlComponent implements OnInit, AfterViewInit, CanCompon
) )
.subscribe({ .subscribe({
next: async ({ video, captions, chapters }) => { next: async ({ video, captions, chapters }) => {
await videoEdit.loadFromAPI({ video, captions, chapters }) await videoEdit.loadFromAPI({ video, captions, chapters, loadPrivacy: false })
this.loadingBar.useRef().complete() this.loadingBar.useRef().complete()

View file

@ -105,7 +105,7 @@ export class VideoGoLiveComponent implements OnInit, AfterViewInit, CanComponent
.subscribe({ .subscribe({
next: async ({ video: { id, uuid, shortUUID }, live }) => { next: async ({ video: { id, uuid, shortUUID }, live }) => {
videoEdit.loadAfterPublish({ video: { id, uuid, shortUUID } }) videoEdit.loadAfterPublish({ video: { id, uuid, shortUUID } })
await videoEdit.loadFromAPI({ live }) await videoEdit.loadFromAPI({ live, loadPrivacy: false })
debugLogger(`Live published`) debugLogger(`Live published`)

View file

@ -1,6 +1,10 @@
import { inject } from '@angular/core'
import { RedirectCommand, Router, Routes } from '@angular/router' import { RedirectCommand, Router, Routes } from '@angular/router'
import { CanDeactivateGuard, LoginGuard } from '@app/core' import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
import debug from 'debug'
import { I18nPrimengCalendarService } from '../shared-manage/common/i18n-primeng-calendar.service' import { I18nPrimengCalendarService } from '../shared-manage/common/i18n-primeng-calendar.service'
import { VideoUploadService } from '../shared-manage/common/video-upload.service' import { VideoUploadService } from '../shared-manage/common/video-upload.service'
import { manageRoutes } from '../shared-manage/routes' import { manageRoutes } from '../shared-manage/routes'
@ -8,9 +12,6 @@ import { VideoStudioService } from '../shared-manage/studio/video-studio.service
import { VideoManageController } from '../shared-manage/video-manage-controller.service' import { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoPublishComponent } from './video-publish.component' import { VideoPublishComponent } from './video-publish.component'
import { VideoPublishResolver } from './video-publish.resolver' import { VideoPublishResolver } from './video-publish.resolver'
import { inject } from '@angular/core'
import debug from 'debug'
import { VideoStateMessageService } from '@app/shared/shared-video/video-state-message.service'
const debugLogger = debug('peertube:video-publish') const debugLogger = debug('peertube:video-publish')
@ -43,6 +44,7 @@ export default [
providers: [ providers: [
VideoPublishResolver, VideoPublishResolver,
VideoManageController, VideoManageController,
PlayerSettingsService,
VideoStateMessageService, VideoStateMessageService,
LiveVideoService, LiveVideoService,
I18nPrimengCalendarService, I18nPrimengCalendarService,

View file

@ -41,8 +41,7 @@ import { VideoPublishResolverData } from './video-publish.resolver'
VideoImportUrlComponent, VideoImportUrlComponent,
VideoUploadComponent, VideoUploadComponent,
HelpComponent HelpComponent
], ]
providers: [ VideoManageController ]
}) })
export class VideoPublishComponent implements OnInit, CanComponentDeactivate { export class VideoPublishComponent implements OnInit, CanComponentDeactivate {
private auth = inject(AuthService) private auth = inject(AuthService)

View file

@ -6,6 +6,8 @@ import {
LiveVideoCreate, LiveVideoCreate,
LiveVideoUpdate, LiveVideoUpdate,
NSFWFlag, NSFWFlag,
PlayerVideoSettings,
PlayerVideoSettingsUpdate,
VideoCaption, VideoCaption,
VideoChapter, VideoChapter,
VideoCreate, VideoCreate,
@ -65,6 +67,8 @@ type StudioForm = {
'add-watermark'?: { file?: File } 'add-watermark'?: { file?: File }
} }
type PlayerSettingsForm = PlayerVideoSettingsUpdate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>> type LoadFromPublishOptions = Required<Pick<VideoCreate, 'channelId' | 'support'>> & Partial<Pick<VideoCreate, 'name'>>
@ -115,6 +119,7 @@ type UpdateFromAPIOptions = {
captions?: VideoCaption[] captions?: VideoCaption[]
videoPasswords?: string[] videoPasswords?: string[]
videoSource?: VideoSource videoSource?: VideoSource
playerSettings?: PlayerVideoSettings
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -143,6 +148,7 @@ export class VideoEdit {
private live: LiveUpdate private live: LiveUpdate
private replaceFile: File private replaceFile: File
private studioTasks: VideoStudioTask[] = [] private studioTasks: VideoStudioTask[] = []
private playerSettings: PlayerVideoSettings
private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'> private videoImport: Pick<VideoImportCreate, 'magnetUri' | 'torrentfile' | 'targetUrl'>
@ -185,6 +191,7 @@ export class VideoEdit {
previewfile?: { size: number } previewfile?: { size: number }
live?: LiveUpdate live?: LiveUpdate
playerSettings?: PlayerVideoSettings
pluginData?: any pluginData?: any
pluginDefaults?: Record<string, string | boolean> pluginDefaults?: Record<string, string | boolean>
@ -293,13 +300,14 @@ export class VideoEdit {
return videoEdit return videoEdit
} }
async loadFromAPI (options: UpdateFromAPIOptions) { async loadFromAPI (options: UpdateFromAPIOptions & { loadPrivacy?: boolean }) {
const { video, videoPasswords, live, chapters, captions, videoSource } = options const { video, videoPasswords, live, chapters, captions, videoSource, playerSettings, loadPrivacy = true } = options
debugLogger('Load from API', options) debugLogger('Load from API', options)
this.loadVideo({ video, videoPasswords, saveInStore: true }) this.loadVideo({ video, videoPasswords, saveInStore: true, loadPrivacy })
this.loadLive(live) this.loadLive(live)
this.loadPlayerSettings(playerSettings)
if (captions !== undefined) { if (captions !== undefined) {
this.captions = captions this.captions = captions
@ -322,18 +330,21 @@ export class VideoEdit {
private loadVideo (options: { private loadVideo (options: {
video: UpdateFromAPIOptions['video'] video: UpdateFromAPIOptions['video']
videoPasswords?: string[] videoPasswords?: string[]
loadPrivacy?: boolean // default true
saveInStore: boolean saveInStore: boolean
}) { }) {
const { video, saveInStore, videoPasswords = [] } = options const { video, saveInStore, loadPrivacy = true, videoPasswords = [] } = options
if (video === undefined) return if (video === undefined) return
const buildObj: () => CommonUpdate = () => { const buildObj: (options: { loadPrivacy: boolean }) => CommonUpdate = () => {
return { const { loadPrivacy } = options
const base = {
...this.common, ...this.common,
name: video.name || '', name: video.name || '',
privacy: video.privacy?.id ?? null,
channelId: video.channel?.id ?? null, channelId: video.channel?.id ?? null,
category: video.category?.id ?? null, category: video.category?.id ?? null,
licence: video.licence?.id ?? null, licence: video.licence?.id ?? null,
@ -361,12 +372,18 @@ export class VideoEdit {
videoPasswords: videoPasswords ?? [] videoPasswords: videoPasswords ?? []
} }
if (loadPrivacy) {
return { ...base, privacy: video.privacy?.id ?? null }
}
return base
} }
this.common = buildObj() this.common = buildObj({ loadPrivacy })
if (saveInStore) { if (saveInStore) {
const obj = buildObj() const obj = buildObj({ loadPrivacy: true })
this.saveStore.common = omit(obj, [ 'pluginData', 'previewfile' ]) this.saveStore.common = omit(obj, [ 'pluginData', 'previewfile' ])
// Apply plugin defaults so we correctly detect changes // Apply plugin defaults so we correctly detect changes
@ -440,6 +457,17 @@ export class VideoEdit {
this.metadata.live = pick(live, [ 'rtmpUrl', 'rtmpsUrl', 'streamKey' ]) this.metadata.live = pick(live, [ 'rtmpUrl', 'rtmpsUrl', 'streamKey' ])
} }
private loadPlayerSettings (playerSettings: UpdateFromAPIOptions['playerSettings']) {
const buildObj = () => {
return {
theme: playerSettings.theme
}
}
this.playerSettings = buildObj()
this.saveStore.playerSettings = buildObj()
}
loadAfterPublish (options: { loadAfterPublish (options: {
video: Pick<VideoDetails, 'id' | 'uuid' | 'shortUUID'> video: Pick<VideoDetails, 'id' | 'uuid' | 'shortUUID'>
}) { }) {
@ -788,6 +816,26 @@ export class VideoEdit {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
loadFromPlayerSettingsForm (values: PlayerSettingsForm) {
this.playerSettings = values
}
toPlayerSettingsFormPatch (): Required<PlayerSettingsForm> {
return {
theme: this.playerSettings?.theme ?? 'channel-default'
}
}
toPlayerSettingsUpdate (): PlayerVideoSettingsUpdate {
if (!this.playerSettings) return undefined
return {
theme: this.playerSettings.theme
}
}
// ---------------------------------------------------------------------------
getVideoSource () { getVideoSource () {
return this.metadata.videoSource return this.metadata.videoSource
} }
@ -816,6 +864,10 @@ export class VideoEdit {
return this.studioTasks return this.studioTasks
} }
getPlayerSettings () {
return this.playerSettings
}
getStudioTasksSummary () { getStudioTasksSummary () {
return this.getStudioTasks().map(t => { return this.getStudioTasks().map(t => {
if (t.name === 'add-intro') { if (t.name === 'add-intro') {
@ -932,6 +984,21 @@ export class VideoEdit {
return changes return changes
} }
hasPlayerSettingsChanges () {
if (!this.playerSettings) return false
if (!this.saveStore.playerSettings) return true
const changes = !this.areSameObjects(this.playerSettings, this.saveStore.playerSettings)
debugLogger('Check if player settings has changes', {
playerSettings: this.playerSettings,
savePlayerSettings: this.saveStore.playerSettings,
changes
})
return changes
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
hasPendingChanges () { hasPendingChanges () {
@ -941,7 +1008,8 @@ export class VideoEdit {
this.hasStudioTasks() || this.hasStudioTasks() ||
this.hasChaptersChanges() || this.hasChaptersChanges() ||
this.hasCommonChanges() || this.hasCommonChanges() ||
this.hasPluginDataChanges() this.hasPluginDataChanges() ||
this.hasPlayerSettingsChanges()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -31,8 +31,15 @@
</div> </div>
<p-datepicker <p-datepicker
inputId="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" [firstDayOfWeek]="0" inputId="originallyPublishedAt"
[showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange" formControlName="originallyPublishedAt"
[dateFormat]="calendarDateFormat"
[firstDayOfWeek]="0"
[showTime]="true"
[hideOnDateTimeSelect]="true"
[monthNavigator]="true"
[yearNavigator]="true"
[yearRange]="myYearRange"
baseZIndex="20000" baseZIndex="20000"
> >
</p-datepicker> </p-datepicker>
@ -42,10 +49,15 @@
</div> </div>
</div> </div>
<my-peertube-checkbox <my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
inputName="downloadEnabled" formControlName="downloadEnabled"
i18n-labelText labelText="Enable download" <div class="form-group" formGroupName="playerSettings">
></my-peertube-checkbox> <label i18n for="playerSettingsTheme">Player Theme</label>
<my-select-player-theme formControlName="theme" inputId="playerSettingsTheme" mode="video" [channel]="videoChannel">
</my-select-player-theme>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,11 +1,12 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgumentTyped } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators' import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig } from '@peertube/peertube-models' import { SelectPlayerThemeComponent } from '@app/shared/shared-forms/select/select-player-theme.component'
import { HTMLServerConfig, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models'
import debug from 'debug' import debug from 'debug'
import { DatePickerModule } from 'primeng/datepicker' import { DatePickerModule } from 'primeng/datepicker'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
@ -19,6 +20,10 @@ const debugLogger = debug('peertube:video-manage')
type Form = { type Form = {
downloadEnabled: FormControl<boolean> downloadEnabled: FormControl<boolean>
originallyPublishedAt: FormControl<Date> originallyPublishedAt: FormControl<Date>
playerSettings: FormGroup<{
theme: FormControl<PlayerVideoSettings['theme']>
}>
} }
@Component({ @Component({
@ -28,12 +33,13 @@ type Form = {
], ],
templateUrl: './video-customization.component.html', templateUrl: './video-customization.component.html',
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgIf,
DatePickerModule, DatePickerModule,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
GlobalIconComponent GlobalIconComponent,
SelectPlayerThemeComponent
] ]
}) })
export class VideoCustomizationComponent implements OnInit, OnDestroy { export class VideoCustomizationComponent implements OnInit, OnDestroy {
@ -47,6 +53,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
validationMessages: FormReactiveMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit
videoChannel: Pick<VideoChannel, 'name' | 'displayName'>
calendarDateFormat: string calendarDateFormat: string
myYearRange: string myYearRange: string
@ -63,17 +70,24 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
const { videoEdit } = this.manageController.getStore() const { videoEdit, userChannels } = this.manageController.getStore()
this.videoEdit = videoEdit this.videoEdit = videoEdit
const channelItem = userChannels.find(c => c.id === videoEdit.toCommonFormPatch().channelId)
this.videoChannel = { name: channelItem.name, displayName: channelItem.label }
this.buildForm() this.buildForm()
} }
private buildForm () { private buildForm () {
const defaultValues = this.videoEdit.toCommonFormPatch() const defaultValues = { ...this.videoEdit.toCommonFormPatch(), playerSettings: this.videoEdit.toPlayerSettingsFormPatch() }
const obj: BuildFormArgument = {
const obj: BuildFormArgumentTyped<Form> = {
downloadEnabled: null, downloadEnabled: null,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
playerSettings: {
theme: null
}
} }
const { const {
@ -93,12 +107,18 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
debugLogger('Updating form values', formValues) debugLogger('Updating form values', formValues)
this.videoEdit.loadFromCommonForm(formValues) this.videoEdit.loadFromCommonForm(formValues)
this.videoEdit.loadFromPlayerSettingsForm({
theme: formValues.playerSettings.theme
})
}) })
this.formReactiveService.markAllAsDirty(this.form.controls) this.formReactiveService.markAllAsDirty(this.form.controls)
this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => { this.updatedSub = this.manageController.getUpdatedObs().subscribe(() => {
this.form.patchValue(this.videoEdit.toCommonFormPatch()) this.form.patchValue({
...this.videoEdit.toCommonFormPatch(),
...this.videoEdit.toPlayerSettingsFormPatch()
})
}) })
} }

View file

@ -184,7 +184,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
.subscribe(res => this.videoCategories = res) .subscribe(res => this.videoCategories = res)
this.serverService.getVideoLicences() this.serverService.getVideoLicences()
.subscribe(res => this.videoLicences = res) .subscribe(res => this.videoLicences = this.videoService.explainedLicenceLabels(res))
this.buildLanguages() this.buildLanguages()
this.buildPrivacies() this.buildPrivacies()
@ -410,7 +410,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
waitTranscodingControl.disable() waitTranscodingControl.disable()
if (!isInitialPatch) waitTranscodingControl.setValue(false) if (!isInitialPatch) waitTranscodingControl.setValue(false)
} else { } else if (waitTranscodingControl.disabled) {
scheduleControl.clearValidators() scheduleControl.clearValidators()
waitTranscodingControl.enable() waitTranscodingControl.enable()

View file

@ -1,7 +1,6 @@
@use "_variables" as *; @use "_variables" as *;
@use "_mixins" as *; @use "_mixins" as *;
@use "_form-mixins" as *; @use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root { .root {
display: flex; display: flex;

View file

@ -9,6 +9,7 @@ import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter
import { VideoPasswordService } from '@app/shared/shared-main/video/video-password.service' import { VideoPasswordService } from '@app/shared/shared-main/video/video-password.service'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
import { import {
HTMLServerConfig, HTMLServerConfig,
@ -47,6 +48,7 @@ export class VideoManageController implements OnDestroy {
private formReactiveService = inject(FormReactiveService) private formReactiveService = inject(FormReactiveService)
private videoStudio = inject(VideoStudioService) private videoStudio = inject(VideoStudioService)
private peertubeRouter = inject(PeerTubeRouterService) private peertubeRouter = inject(PeerTubeRouterService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEdit: VideoEdit private videoEdit: VideoEdit
private userChannels: SelectChannelItem[] private userChannels: SelectChannelItem[]
@ -245,6 +247,16 @@ export class VideoManageController implements OnDestroy {
return this.videoChapterService.updateChapters(videoAttributes.uuid, this.videoEdit.getChaptersEdit()) return this.videoChapterService.updateChapters(videoAttributes.uuid, this.videoEdit.getChaptersEdit())
}), }),
switchMap(() => {
if (!this.videoEdit.hasPlayerSettingsChanges()) return of(true)
debugLogger('Update player settings')
return this.playerSettingsService.updateVideoSettings({
videoId: videoAttributes.uuid,
settings: this.videoEdit.getPlayerSettings()
})
}),
switchMap(() => { switchMap(() => {
if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true) if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true)
@ -283,16 +295,19 @@ export class VideoManageController implements OnDestroy {
!isLive !isLive
? this.videoCaptionService.listCaptions(videoAttributes.uuid) ? this.videoCaptionService.listCaptions(videoAttributes.uuid)
: of(undefined) : of(undefined),
this.playerSettingsService.getVideoSettings({ videoId: videoAttributes.uuid, raw: true })
]) ])
}), }),
switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes ]) => { switchMap(([ video, videoPasswords, live, chaptersRes, captionsRes, playerSettings ]) => {
return this.videoEdit.loadFromAPI({ return this.videoEdit.loadFromAPI({
video, video,
videoPasswords: videoPasswords.map(p => p.password), videoPasswords: videoPasswords.map(p => p.password),
live, live,
chapters: chaptersRes?.chapters, chapters: chaptersRes?.chapters,
captions: captionsRes?.data captions: captionsRes?.data,
playerSettings
}) })
}), }),
first(), // To complete first(), // To complete

View file

@ -8,6 +8,7 @@ import {
ServerStats, ServerStats,
VideoCommentPolicy, VideoCommentPolicy,
VideoConstant, VideoConstant,
VideoLicenceType,
VideoPlaylistPrivacyType, VideoPlaylistPrivacyType,
VideoPrivacyType VideoPrivacyType
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
@ -30,7 +31,7 @@ export class ServerService {
configReloaded = new Subject<ServerConfig>() configReloaded = new Subject<ServerConfig>()
private localeObservable: Observable<any> private localeObservable: Observable<any>
private videoLicensesObservable: Observable<VideoConstant<number>[]> private videoLicensesObservable: Observable<VideoConstant<VideoLicenceType>[]>
private videoCategoriesObservable: Observable<VideoConstant<number>[]> private videoCategoriesObservable: Observable<VideoConstant<number>[]>
private videoPrivaciesObservable: Observable<VideoConstant<VideoPrivacyType>[]> private videoPrivaciesObservable: Observable<VideoConstant<VideoPrivacyType>[]>
private videoPlaylistPrivaciesObservable: Observable<VideoConstant<VideoPlaylistPrivacyType>[]> private videoPlaylistPrivaciesObservable: Observable<VideoConstant<VideoPlaylistPrivacyType>[]>
@ -129,7 +130,7 @@ export class ServerService {
getVideoLicences () { getVideoLicences () {
if (!this.videoLicensesObservable) { if (!this.videoLicensesObservable) {
this.videoLicensesObservable = this.loadAttributeEnum<number>(ServerService.BASE_VIDEO_URL, 'licences') this.videoLicensesObservable = this.loadAttributeEnum<VideoLicenceType>(ServerService.BASE_VIDEO_URL, 'licences')
} }
return this.videoLicensesObservable.pipe(first()) return this.videoLicensesObservable.pipe(first())

View file

@ -37,7 +37,7 @@
<my-notification-dropdown class="margin-button"></my-notification-dropdown> <my-notification-dropdown class="margin-button"></my-notification-dropdown>
<my-button <my-button
i18n-title title="Go to the manage your account page" i18n-title title="Go to the page where you can manage your account"
theme="tertiary" rounded="true" class="margin-button settings-button" icon="cog" ptRouterLink="/my-account" theme="tertiary" rounded="true" class="margin-button settings-button" icon="cog" ptRouterLink="/my-account"
></my-button> ></my-button>

View file

@ -11,7 +11,7 @@
<h4 i18n class="title">General information</h4> <h4 i18n class="title">General information</h4>
<div class="text-content">You can edit this information later</div> <div i18n class="text-content">You can edit this information later</div>
<form [formGroup]="form"> <form [formGroup]="form">
<div class="form-group"> <div class="form-group">

View file

@ -47,59 +47,59 @@ export class CommunityBasedConfigComponent implements OnInit {
registrationOptions: SelectOptionsItem<RegistrationType>[] = [ registrationOptions: SelectOptionsItem<RegistrationType>[] = [
{ {
id: 'open', id: 'open',
label: 'Open', label: $localize`Open`,
description: 'Anyone can register and use the platform' description: $localize`Anyone can register and use the platform`
}, },
{ {
id: 'approval', id: 'approval',
label: 'Requires approval', label: $localize`Requires approval`,
description: 'Anyone can register, but a moderator must approve their account before they can use the platform' description: $localize`Anyone can register, but a moderator must approve their account before they can use the platform`
}, },
{ {
id: 'closed', id: 'closed',
label: 'Closed', label: $localize`Closed`,
description: 'Only an administrator can create users on the platform' description: $localize`Only an administrator can create users on the platform`
} }
] ]
importOptions: SelectOptionsItem<EnabledDisabled>[] = [ importOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Enabled', label: $localize`Enabled`,
description: description:
'Your community can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels' 'Your community can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels'
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'Disabled', label: $localize`Disabled`,
description: 'Your community cannot import or synchronize content from remote platforms' description: $localize`Your community cannot import or synchronize content from remote platforms`
} }
] ]
liveOptions: SelectOptionsItem<EnabledDisabled>[] = [ liveOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Yes', label: $localize`Yes`,
description: 'Your community can live stream on the platform (this requires extra moderation work)' description: $localize`Your community can live stream on the platform (this requires extra moderation work)`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'No', label: $localize`No`,
description: 'Your community is not permitted to run live streams on the platform' description: $localize`Your community is not permitted to run live streams on the platform`
} }
] ]
globalSearchOptions: SelectOptionsItem<string>[] = [ globalSearchOptions: SelectOptionsItem<string>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Enable global search', label: $localize`Enable global search`,
description: 'Use https://sepiasearch.org as default search engine to search for content across all known peertube platforms' description: $localize`Use https://sepiasearch.org as default search engine to search for content across all known PeerTube platforms`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'Disable global search', label: $localize`Disable global search`,
description: 'Use your platform search engine which only displays local content' description: $localize`Use your platform search engine which only displays local content`
} }
] ]

View file

@ -45,62 +45,62 @@ export class InstitutionalConfigComponent implements OnInit {
p2pOptions: SelectOptionsItem<EnabledDisabled>[] = [ p2pOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Enabled', label: $localize`Enabled`,
description: 'Enable P2P streaming by default for anonymous and new users' description: $localize`Enable P2P streaming by default for anonymous and new users`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'Disabled', label: $localize`Disabled`,
description: 'Disable P2P streaming' description: $localize`Disable P2P streaming`
} }
] ]
transcriptionOptions: SelectOptionsItem<EnabledDisabled>[] = [ transcriptionOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Enabled', label: $localize`Enabled`,
description: 'Enable automatic transcription of videos to automatically generate subtitles' description: $localize`Enable automatic transcription of videos to automatically generate subtitles`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'Disabled', label: $localize`Disabled`,
description: 'Disable automatic transcription of videos' description: $localize`Disable automatic transcription of videos`
} }
] ]
keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [ keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Yes', label: $localize`Yes`,
description: 'Keep the original video file on the server' description: $localize`Keep the original video file on the server`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'No', label: $localize`No`,
description: 'Delete the original video file after processing' description: $localize`Delete the original video file after processing`
} }
] ]
authenticationOptions: SelectOptionsItem<AuthType>[] = [ authenticationOptions: SelectOptionsItem<AuthType>[] = [
{ {
id: 'local', id: 'local',
label: 'Disabled', label: $localize`Disabled`,
description: 'Your platform will manage user registration and login internally' description: $localize`Your platform will manage user registration and login internally`
}, },
{ {
id: 'ldap', id: 'ldap',
label: 'LDAP', label: $localize`LDAP`,
description: 'Use LDAP for user authentication' description: $localize`Use LDAP for user authentication`
}, },
{ {
id: 'oidc', id: 'oidc',
label: 'OIDC', label: $localize`OIDC`,
description: 'Use OpenID Connect for user authentication' description: $localize`Use OpenID Connect for user authentication`
}, },
{ {
id: 'saml', id: 'saml',
label: 'SAML', label: $localize`SAML`,
description: 'Use SAML 2.0 for user authentication' description: $localize`Use SAML 2.0 for user authentication`
} }
] ]

View file

@ -44,37 +44,38 @@ export class PrivateInstanceConfigComponent implements OnInit {
importOptions: SelectOptionsItem<EnabledDisabled>[] = [ importOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Enabled', label: $localize`Enabled`,
description: 'Users can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels' description:
$localize`Users can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'Disabled', label: $localize`Disabled`,
description: 'Disable video import and channel synchronization' description: $localize`Disable video import and channel synchronization`
} }
] ]
liveOptions: SelectOptionsItem<EnabledDisabled>[] = [ liveOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Yes' label: $localize`Yes`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'No' label: $localize`No`
} }
] ]
keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [ keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [
{ {
id: 'enabled', id: 'enabled',
label: 'Yes', label: $localize`Yes`,
description: 'Keep the original video file on the server' description: $localize`Keep the original video file on the server`
}, },
{ {
id: 'disabled', id: 'disabled',
label: 'No', label: $localize`No`,
description: 'Delete the original video file after processing' description: $localize`Delete the original video file after processing`
} }
] ]

View file

@ -160,7 +160,7 @@ export class UsageType {
if (!exists(this.registration)) return if (!exists(this.registration)) return
if (this.registration === 'open') { if (this.registration === 'open') {
this.addExplanation($localize`<strong>Allow</strong> any user <strong>to register</strong>`) this.addExplanation($localize`:bullet point of "PeerTube will\:":<strong>Allow</strong> any user <strong>to register</strong>`)
this.addConfig({ this.addConfig({
signup: { signup: {
@ -169,7 +169,9 @@ export class UsageType {
} }
}) })
} else if (this.registration === 'approval') { } else if (this.registration === 'approval') {
this.addExplanation($localize`Allow users to <strong>apply for registration</strong> on your platform`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Allow users to <strong>apply for registration</strong> on your platform`
)
this.addConfig({ this.addConfig({
signup: { signup: {
@ -178,7 +180,7 @@ export class UsageType {
} }
}) })
} else if (this.registration === 'closed') { } else if (this.registration === 'closed') {
this.addExplanation($localize`<strong>Disable</strong> user <strong>registration</strong>`) this.addExplanation($localize`:bullet point of "PeerTube will\:":<strong>Disable</strong> user <strong>registration</strong>`)
this.addConfig({ this.addConfig({
signup: { signup: {
@ -188,7 +190,9 @@ export class UsageType {
} }
if (this.registration === 'approval' || this.registration === 'open') { if (this.registration === 'approval' || this.registration === 'open') {
this.addExplanation($localize`Require <strong>moderator approval</strong> for videos published by your community`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Require <strong>moderator approval</strong> for videos published by your community`
)
this.addConfig({ this.addConfig({
autoBlacklist: { autoBlacklist: {
@ -213,13 +217,18 @@ export class UsageType {
if (this.videoQuota === 0) { if (this.videoQuota === 0) {
this.addExplanation( this.addExplanation(
$localize`<strong>Prevent</strong> new users <strong>from uploading videos</strong> (can be changed by moderators)` // eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Prevent</strong> new users <strong>from uploading videos</strong> (can be changed by moderators)`
) )
} else if (this.videoQuota === -1) { } else if (this.videoQuota === -1) {
this.addExplanation($localize`Will <strong>not limit the amount of videos</strong> new users can upload`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":<strong>Not limit the amount of videos</strong> new users can upload`
)
} else { } else {
this.addExplanation( this.addExplanation(
$localize`Set <strong>video quota to ${getBytes(this.videoQuota, 0)}</strong> for new users (can be changed by moderators)` $localize`:bullet point of "PeerTube will\:":Set <strong>video quota to ${
getBytes(this.videoQuota, 0)
}</strong> for new users (can be changed by moderators)`
) )
} }
} }
@ -243,10 +252,13 @@ export class UsageType {
if (this.remoteImport === 'enabled') { if (this.remoteImport === 'enabled') {
this.addExplanation( this.addExplanation(
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
$localize`<strong>Allow</strong> your users <strong>to import and synchronize</strong> videos from remote platforms (YouTube, Vimeo...)` $localize`:bullet point of "PeerTube will\:":<strong>Allow</strong> your users <strong>to import and synchronize</strong> videos from remote platforms (YouTube, Vimeo...)`
) )
} else { } else {
this.addExplanation($localize`<strong>Prevent</strong> your users <strong>from importing videos</strong> from remote platforms`) this.addExplanation(
// eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Prevent</strong> your users <strong>from importing videos</strong> from remote platforms`
)
} }
} }
@ -264,10 +276,12 @@ export class UsageType {
this.addExplanation( this.addExplanation(
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
$localize`<strong>Allow</strong> your users <strong>to stream lives</strong> and chat with their viewers using the <strong>Livechat</strong> plugin` $localize`:bullet point of "PeerTube will\:":<strong>Allow</strong> your users <strong>to stream lives</strong> and chat with their viewers using the <strong>Livechat</strong> plugin`
) )
} else { } else {
this.addExplanation($localize`<strong>Prevent</strong> your users from running <strong>live streams</strong>`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":<strong>Prevent</strong> your users from running <strong>live streams</strong>`
)
} }
} }
@ -283,9 +297,13 @@ export class UsageType {
}) })
if (this.defaultPrivacy === VideoPrivacy.INTERNAL) { if (this.defaultPrivacy === VideoPrivacy.INTERNAL) {
this.addExplanation($localize`Set the <strong>default video privacy</strong> to <strong>Internal</strong>`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Set the <strong>default video privacy</strong> to <strong>Internal</strong>`
)
} else if (this.defaultPrivacy === VideoPrivacy.PUBLIC) { } else if (this.defaultPrivacy === VideoPrivacy.PUBLIC) {
this.addExplanation($localize`Set the <strong>default video privacy</strong> to <strong>Public</strong>`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Set the <strong>default video privacy</strong> to <strong>Public</strong>`
)
} }
} }
@ -300,7 +318,7 @@ export class UsageType {
} }
}) })
this.addExplanation($localize`<strong>Require approval</strong> by default of new video comment`) this.addExplanation($localize`:bullet point of "PeerTube will\:":<strong>Require approval</strong> by default of new video comment`)
} }
private computeP2P () { private computeP2P () {
@ -320,9 +338,13 @@ export class UsageType {
}) })
if (this.p2p === 'enabled') { if (this.p2p === 'enabled') {
this.addExplanation($localize`<strong>Enable P2P streaming</strong> by default for anonymous and new users`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":<strong>Enable P2P streaming</strong> by default for anonymous and new users`
)
} else { } else {
this.addExplanation($localize`<strong>Disable P2P streaming</strong> by default for anonymous and new users`) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":<strong>Disable P2P streaming</strong> by default for anonymous and new users`
)
} }
} }
@ -341,9 +363,15 @@ export class UsageType {
}) })
if (this.federation === 'enabled') { if (this.federation === 'enabled') {
this.addExplanation($localize`<strong>Allow</strong> external platforms/users to <strong>subscribe</strong> to your content`) this.addExplanation(
// eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Allow</strong> external platforms/users to <strong>subscribe</strong> to your content`
)
} else { } else {
this.addExplanation($localize`<strong>Prevent</strong> external platforms/users to <strong>subscribe to your content</strong>`) this.addExplanation(
// eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Prevent</strong> external platforms/users to <strong>subscribe to your content</strong>`
)
} }
} }
@ -359,7 +387,7 @@ export class UsageType {
}) })
if (this.keepOriginalVideo === 'enabled') { if (this.keepOriginalVideo === 'enabled') {
this.addExplanation($localize`Will <strong>save a copy</strong> of the uploaded video file`) this.addExplanation($localize`:bullet point of "PeerTube will\:":<strong>Save a copy</strong> of the uploaded video file`)
} }
} }
@ -376,7 +404,8 @@ export class UsageType {
if (this.allowReplaceFile === 'enabled') { if (this.allowReplaceFile === 'enabled') {
this.addExplanation( this.addExplanation(
$localize`Will <strong>allow</strong> your users <strong>to replace a video</strong> that has already been published` // eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Allow</strong> your users <strong>to replace a video</strong> that has already been published`
) )
} }
} }
@ -410,7 +439,8 @@ export class UsageType {
if (this.globalSearch === 'enabled') { if (this.globalSearch === 'enabled') {
this.addExplanation( this.addExplanation(
$localize`Set <a href="https://sepiasearch.org" target="_blank">SepiaSearch</a> as <strong>default search engine</strong>` // eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":Set <a href="https://sepiasearch.org" target="_blank">SepiaSearch</a> as <strong>default search engine</strong>`
) )
} }
} }
@ -426,7 +456,8 @@ export class UsageType {
if (this.transcription === 'enabled') { if (this.transcription === 'enabled') {
this.addExplanation( this.addExplanation(
$localize`<strong>Enable automatic transcription</strong> of videos to create subtitles and improve accessibility` // eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:":<strong>Enable automatic transcription</strong> of videos to create subtitles and improve accessibility`
) )
} }
} }
@ -434,18 +465,26 @@ export class UsageType {
private computeAuth () { private computeAuth () {
if (!exists(this.authType)) return if (!exists(this.authType)) return
const configStr = $localize` The plugin <strong>must be configured</strong> after the pre-configuration wizard confirmation.` const configStr =
// eslint-disable-next-line max-len
$localize`:bullet point of "PeerTube will\:": The plugin <strong>must be configured</strong> after the pre-configuration wizard confirmation.`
if (this.authType === 'ldap') { if (this.authType === 'ldap') {
this.addExplanation($localize`Install the <strong>LDAP</strong> authentication plugin.` + configStr) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Install the <strong>LDAP</strong> authentication plugin.` + configStr
)
this.plugins.push('peertube-plugin-auth-ldap') this.plugins.push('peertube-plugin-auth-ldap')
} else if (this.authType === 'saml') { } else if (this.authType === 'saml') {
this.addExplanation($localize`Install the <strong>SAML 2.0</strong> authentication plugin.` + configStr) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Install the <strong>SAML 2.0</strong> authentication plugin.` + configStr
)
this.plugins.push('peertube-plugin-auth-saml2') this.plugins.push('peertube-plugin-auth-saml2')
} else if (this.authType === 'oidc') { } else if (this.authType === 'oidc') {
this.addExplanation($localize`Install the <strong>OpenID Connect</strong> authentication plugin.` + configStr) this.addExplanation(
$localize`:bullet point of "PeerTube will\:":Install the <strong>OpenID Connect</strong> authentication plugin.` + configStr
)
this.plugins.push('peertube-plugin-auth-openid-connect') this.plugins.push('peertube-plugin-auth-openid-connect')
} }

View file

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor"> <div class="actor" *ngIf="actor">
<div class="position-relative me-3"> <div class="position-relative me-3">
<my-actor-avatar [actor]="actor" [actorType]="actorType()" [previewImage]="preview" size="100"></my-actor-avatar> <my-actor-avatar [actor]="actor" [actorType]="actorType()" [previewImage]="previewUrl" size="100"></my-actor-avatar>
@if (editable()) { @if (editable()) {
@if (hasAvatar()) { @if (hasAvatar()) {

View file

@ -41,7 +41,7 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
maxAvatarSize = 0 maxAvatarSize = 0
avatarExtensions = '' avatarExtensions = ''
preview: string previewUrl: string
actor: ActorAvatarInput actor: ActorAvatarInput
@ -55,6 +55,8 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
} }
ngOnChanges () { ngOnChanges () {
this.previewUrl = undefined
this.actor = { this.actor = {
avatars: this.avatars(), avatars: this.avatars(),
name: this.username() name: this.username()
@ -73,16 +75,23 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
this.avatarChange.emit(formData) this.avatarChange.emit(formData)
if (this.previewImage()) { if (this.previewImage()) {
imageToDataURL(avatarfile).then(result => this.preview = result) imageToDataURL(avatarfile).then(result => this.previewUrl = result)
} }
} }
deleteAvatar () { deleteAvatar () {
this.preview = undefined if (this.previewImage()) {
this.previewUrl = null
this.actor.avatars = []
}
this.avatarDelete.emit() this.avatarDelete.emit()
} }
hasAvatar () { hasAvatar () {
return !!this.preview || this.avatars().length !== 0 // User deleted the avatar
if (this.previewUrl === null) return false
return !!this.previewUrl || this.avatars().length !== 0
} }
} }

View file

@ -1,30 +1,32 @@
<div class="actor"> <div class="actor">
<div class="actor-img-edit-container"> <div class="actor-img-edit-container">
<div class="banner-placeholder"> <div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || bannerUrl()" alt="Banner" /> <img *ngIf="hasBanner()" [src]="getBannerUrl()" alt="Banner" />
</div> </div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body"> @if (!hasBanner()) {
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container> <div class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
</div> <ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
<div *ngIf="hasBanner()" ngbDropdown placement="right">
<button type="button" class="actor-img-edit-button button-file primary-button" ngbDropdownToggle>
<my-global-icon iconName="edit"></my-global-icon>
<span i18n>Change your banner</span>
</button>
<div ngbDropdownMenu>
<div class="dropdown-item dropdown-file button-focus-within" [ngbTooltip]="bannerFormat">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<button type="button" class="dropdown-item" (click)="deleteBanner()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove banner</span>
</button>
</div> </div>
</div> } @else {
<div ngbDropdown placement="right">
<button type="button" class="actor-img-edit-button button-file primary-button" ngbDropdownToggle>
<my-global-icon iconName="edit"></my-global-icon>
<span i18n>Change your banner</span>
</button>
<div ngbDropdownMenu>
<div class="dropdown-item dropdown-file button-focus-within" [ngbTooltip]="bannerFormat">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<button type="button" class="dropdown-item" (click)="deleteBanner()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove banner</span>
</button>
</div>
</div>
}
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
import { NgIf, NgTemplateOutlet } from '@angular/common' import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, inject, input, output, viewChild } from '@angular/core' import { Component, ElementRef, OnInit, booleanAttribute, inject, input, output, viewChild } from '@angular/core'
import { SafeResourceUrl } from '@angular/platform-browser' import { SafeResourceUrl } from '@angular/platform-browser'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbPopover, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes' import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images' import { imageToDataURL } from '@root-helpers/images'
import { GlobalIconComponent } from '../shared-icons/global-icon.component' import { GlobalIconComponent } from '../shared-icons/global-icon.component'
@ -14,7 +14,7 @@ import { GlobalIconComponent } from '../shared-icons/global-icon.component'
'./actor-image-edit.scss', './actor-image-edit.scss',
'./actor-banner-edit.component.scss' './actor-banner-edit.component.scss'
], ],
imports: [ NgIf, NgbTooltip, NgTemplateOutlet, NgbDropdown, NgbDropdownToggle, GlobalIconComponent, NgbDropdownMenu ] imports: [ CommonModule, NgbTooltipModule, NgTemplateOutlet, NgbDropdownModule, GlobalIconComponent ]
}) })
export class ActorBannerEditComponent implements OnInit { export class ActorBannerEditComponent implements OnInit {
private serverService = inject(ServerService) private serverService = inject(ServerService)
@ -23,8 +23,8 @@ export class ActorBannerEditComponent implements OnInit {
readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput') readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput')
readonly bannerPopover = viewChild<NgbPopover>('bannerPopover') readonly bannerPopover = viewChild<NgbPopover>('bannerPopover')
readonly bannerUrl = input<string>(undefined) readonly bannerUrl = input<string>()
readonly previewImage = input(false) readonly previewImage = input(false, { transform: booleanAttribute })
readonly bannerChange = output<FormData>() readonly bannerChange = output<FormData>()
readonly bannerDelete = output() readonly bannerDelete = output()
@ -63,11 +63,23 @@ export class ActorBannerEditComponent implements OnInit {
} }
deleteBanner () { deleteBanner () {
this.preview = undefined if (this.previewImage()) {
this.preview = null
}
this.bannerDelete.emit() this.bannerDelete.emit()
} }
hasBanner () { hasBanner () {
// User deleted the avatar
if (this.preview === null) return false
return !!this.preview || !!this.bannerUrl() return !!this.preview || !!this.bannerUrl()
} }
getBannerUrl () {
if (this.preview === null) return ''
return this.preview || this.bannerUrl()
}
} }

View file

@ -35,7 +35,6 @@ export class AdminConfigService {
private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
transcodingThreadOptions: SelectOptionsItem[] = [] transcodingThreadOptions: SelectOptionsItem[] = []
transcodingResolutionOptions: ResolutionOption[] = []
constructor () { constructor () {
this.transcodingThreadOptions = [ this.transcodingThreadOptions = [
@ -48,13 +47,18 @@ export class AdminConfigService {
{ id: 16, label: '16' }, { id: 16, label: '16' },
{ id: 32, label: '32' } { id: 32, label: '32' }
] ]
}
this.transcodingResolutionOptions = [ // ---------------------------------------------------------------------------
getTranscodingOptions (type: 'live' | 'vod'): ResolutionOption[] {
return [
{ {
id: '0p', id: '0p',
label: $localize`Audio-only`, label: $localize`Audio-only`,
description: description: type === 'vod'
$localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users` ? $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
: undefined
}, },
{ {
id: '144p', id: '144p',
@ -141,10 +145,10 @@ export class AdminConfigService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
buildFormResolutions () { buildFormResolutions (type: 'live' | 'vod') {
const formResolutions = {} as Record<keyof FormResolutions, BuildFormValidator> const formResolutions = {} as Record<keyof FormResolutions, BuildFormValidator>
for (const resolution of this.transcodingResolutionOptions) { for (const resolution of this.getTranscodingOptions(type)) {
formResolutions[resolution.id] = null formResolutions[resolution.id] = null
} }

View file

@ -1,2 +1,2 @@
<my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist"> <my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist" toManage="false">
</my-video-playlist-miniature> </my-video-playlist-miniature>

View file

@ -27,7 +27,7 @@
<ng-template [ngTemplateOutlet]="customItemTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template> <ng-template [ngTemplateOutlet]="customItemTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
} @else { } @else {
<div> <div>
<div class="d-flex align-items-center item-label"> <div class="d-flex align-items-center item-label custom-item">
<img *ngIf="item.imageUrl" alt="" class="me-2" [src]="item.imageUrl" /> <img *ngIf="item.imageUrl" alt="" class="me-2" [src]="item.imageUrl" />
<span class="ellipsis" [ngClass]="item.classes">{{ item.label }}</span> <span class="ellipsis" [ngClass]="item.classes">{{ item.label }}</span>

View file

@ -1,5 +1,5 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
img { img {
border-radius: 50%; border-radius: 50%;
@ -10,8 +10,15 @@ img {
.muted { .muted {
font-size: 90%; font-size: 90%;
line-height: 1.2;
display: block;
white-space: normal;
} }
.item-label { .item-label {
min-height: 24px; min-height: 24px;
} }
p-select ::ng-deep p-overlay {
max-width: 100%;
}

View file

@ -0,0 +1,129 @@
import { CommonModule } from '@angular/common'
import { Component, forwardRef, inject, input, OnInit } from '@angular/core'
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { ServerService } from '@app/core'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { PlayerChannelSettings, PlayerTheme, PlayerVideoSettings, VideoChannel } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { SelectOptionsComponent } from './select-options.component'
@Component({
selector: 'my-select-player-theme',
template: `
<my-select-options
[inputId]="inputId()"
[items]="themes"
[(ngModel)]="selectedId"
(ngModelChange)="onModelChange()"
filter="false"
></my-select-options>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectPlayerThemeComponent),
multi: true
}
],
imports: [ FormsModule, CommonModule, SelectOptionsComponent ]
})
export class SelectPlayerThemeComponent implements ControlValueAccessor, OnInit {
private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
readonly inputId = input.required<string>()
readonly mode = input.required<'instance' | 'video' | 'channel'>()
readonly channel = input<Pick<VideoChannel, 'name' | 'displayName'>>()
themes: SelectOptionsItem<PlayerVideoSettings['theme']>[]
selectedId: PlayerTheme
ngOnInit () {
if (this.mode() === 'video' && !this.channel()) {
throw new Error('Channel must be specified in video mode')
}
this.buildOptions()
}
propagateChange = (_: any) => {
// empty
}
writeValue (id: PlayerTheme) {
this.selectedId = id
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedId)
}
private buildOptions () {
const config = this.serverService.getHTMLConfig()
const instanceName = config.instance.name
const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme)
this.themes = []
if (this.mode() === 'channel' || this.mode() === 'video') {
this.themes.push(
{ id: 'instance-default', label: $localize`${instanceName} setting (${instancePlayerTheme})` }
)
}
if (this.mode() === 'video') {
this.themes.push(
{ id: 'channel-default', label: $localize`${this.channel().displayName} setting` }
)
this.scheduleChannelUpdate()
}
this.themes = this.themes.concat(this.getPlayerThemes())
}
private scheduleChannelUpdate () {
this.playerSettingsService.getChannelSettings({ channelHandle: this.channel().name, raw: true }).subscribe({
next: settings => {
this.themes.find(t => t.id === 'channel-default').label = this.buildChannelLabel(settings)
}
})
}
private buildChannelLabel (channelRawPlayerSettings: PlayerChannelSettings) {
const config = this.serverService.getHTMLConfig()
const instanceName = config.instance.name
const instancePlayerTheme = this.getLabelOf(config.defaults.player.theme)
const channelRawTheme = channelRawPlayerSettings.theme
const channelPlayerTheme = channelRawTheme === 'instance-default'
? $localize`from ${instanceName} setting\: ${instancePlayerTheme}`
: this.getLabelOf(channelRawTheme)
return $localize`${this.channel().displayName} channel setting (${channelPlayerTheme})`
}
private getLabelOf (playerTheme: PlayerTheme) {
return this.getPlayerThemes().find(t => t.id === playerTheme)?.label
}
private getPlayerThemes (): SelectOptionsItem<PlayerVideoSettings['theme']>[] {
return [
{ id: 'galaxy', label: $localize`Galaxy`, description: $localize`Original theme` },
{ id: 'lucide', label: $localize`Lucide`, description: $localize`A clean and modern theme` }
]
}
}

View file

@ -1,5 +1,4 @@
import { AfterViewChecked, booleanAttribute, Directive, ElementRef, inject, input, OnDestroy, OnInit, output } from '@angular/core' import { AfterViewChecked, booleanAttribute, Directive, ElementRef, inject, input, OnDestroy, OnInit, output } from '@angular/core'
import { PeerTubeRouterService } from '@app/core'
import { fromEvent, Observable, Subscription } from 'rxjs' import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
@ -8,7 +7,6 @@ import { distinctUntilChanged, filter, map, share, startWith, throttleTime } fro
standalone: true standalone: true
}) })
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
private peertubeRouter = inject(PeerTubeRouterService)
private el = inject(ElementRef) private el = inject(ElementRef)
readonly percentLimit = input(70) readonly percentLimit = input(70)
@ -18,7 +16,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
readonly nearOfBottom = output() readonly nearOfBottom = output()
private decimalLimit = 0 private decimalLimit = 0
private lastCurrentBottom = -1 private lastCurrentBottom: number
private scrollDownSub: Subscription private scrollDownSub: Subscription
private container: HTMLElement private container: HTMLElement
@ -98,6 +96,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
} }
private isScrollingDown (current: number) { private isScrollingDown (current: number) {
if (this.lastCurrentBottom === undefined) {
this.lastCurrentBottom = current
return false
}
const result = this.lastCurrentBottom < current const result = this.lastCurrentBottom < current
this.lastCurrentBottom = current this.lastCurrentBottom = current

View file

@ -1,7 +1,6 @@
@use "_variables" as *; @use "_variables" as *;
@use "_mixins" as *; @use "_mixins" as *;
@use "_form-mixins" as *; @use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
h1 { h1 {
color: pvar(--fg-200); color: pvar(--fg-200);

View file

@ -31,6 +31,8 @@ import {
VideoDetails as VideoDetailsServerModel, VideoDetails as VideoDetailsServerModel,
VideoFile, VideoFile,
VideoFileMetadata, VideoFileMetadata,
VideoLicence,
VideoLicenceType,
VideoPrivacy, VideoPrivacy,
VideoPrivacyType, VideoPrivacyType,
VideosCommonQuery, VideosCommonQuery,
@ -526,6 +528,8 @@ export class VideoService {
) )
} }
// ---------------------------------------------------------------------------
explainedPrivacyLabels (serverPrivacies: VideoConstant<VideoPrivacyType>[], defaultPrivacyId: VideoPrivacyType = VideoPrivacy.PUBLIC) { explainedPrivacyLabels (serverPrivacies: VideoConstant<VideoPrivacyType>[], defaultPrivacyId: VideoPrivacyType = VideoPrivacy.PUBLIC) {
const descriptions = { const descriptions = {
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
@ -549,6 +553,30 @@ export class VideoService {
} }
} }
explainedLicenceLabels (serverLicences: VideoConstant<VideoLicenceType>[]) {
const descriptions = {
[VideoLicence['CC-BY']]: $localize`CC-BY`,
[VideoLicence['CC-BY-SA']]: $localize`CC-BY-SA`,
[VideoLicence['CC-BY-ND']]: $localize`CC-BY-ND`,
[VideoLicence['CC-BY-NC']]: $localize`CC-BY-NC`,
[VideoLicence['CC-BY-NC-SA']]: $localize`CC-BY-NC-SA`,
[VideoLicence['CC-BY-NC-ND']]: $localize`CC-BY-NC-ND`,
[VideoLicence['CC0']]: '',
[VideoLicence.PDM]: $localize`Public domain mark`,
[VideoLicence['COPYRIGHT']]: 'You are the owner of the content or you have the rights of the copyright holders'
}
return serverLicences.map(p => {
return {
...p,
description: descriptions[p.id]
}
})
}
// ---------------------------------------------------------------------------
buildNSFWTooltip (video: Pick<VideoServerModel, 'nsfw' | 'nsfwFlags'>) { buildNSFWTooltip (video: Pick<VideoServerModel, 'nsfw' | 'nsfwFlags'>) {
const flags: string[] = [] const flags: string[] = []

View file

@ -27,7 +27,7 @@
@if (isInSelectionMode()) { @if (isInSelectionMode()) {
<my-action-dropdown i18n-label label="Batch actions" theme="primary" [actions]="bulkActions()" [entry]="selectedRows"></my-action-dropdown> <my-action-dropdown i18n-label label="Batch actions" theme="primary" [actions]="bulkActions()" [entry]="selectedRows"></my-action-dropdown>
} @else { } @else {
<strong *ngIf="totalTitle" i18n [ngClass]="{ 'opacity-0': loading }"> <strong *ngIf="totalTitle" [ngClass]="{ 'opacity-0': loading }">
<ng-template *ngTemplateOutlet="totalTitle; context: { $implicit: totalRecords }"></ng-template> <ng-template *ngTemplateOutlet="totalTitle; context: { $implicit: totalRecords }"></ng-template>
</strong> </strong>

View file

@ -95,9 +95,6 @@ export class VideoFilters {
if (noChanges) return if (noChanges) return
console.log(currentFormObjectString)
console.log(this.oldFormObjectString)
this.oldFormObjectString = currentFormObjectString this.oldFormObjectString = currentFormObjectString
for (const cb of this.onChangeCallbacks) { for (const cb of this.onChangeCallbacks) {

View file

@ -3,7 +3,7 @@
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true" [internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[title]="playlist().description" class="miniature-thumbnail" tabindex="-1" [title]="playlist().description" class="miniature-thumbnail" tabindex="-1"
> >
<img alt="" [attr.aria-labelledby]="playlist().displayName" [attr.src]="playlist().thumbnailUrl" /> <img alt="" [attr.aria-label]="playlist().displayName" [attr.src]="playlist().thumbnailUrl" />
<div class="miniature-playlist-info-overlay"> <div class="miniature-playlist-info-overlay">
<ng-container i18n>{playlist().videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist().videosLength }} videos}}</ng-container> <ng-container i18n>{playlist().videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist().videosLength }} videos}}</ng-container>

View file

@ -17,7 +17,7 @@ export class VideoPlaylistMiniatureComponent implements OnInit {
readonly playlist = input<VideoPlaylist>(undefined) readonly playlist = input<VideoPlaylist>(undefined)
readonly toManage = input(false) readonly toManage = input.required({ transform: booleanAttribute })
readonly thumbnailOnly = input(false, { transform: booleanAttribute }) readonly thumbnailOnly = input(false, { transform: booleanAttribute })

View file

@ -0,0 +1,71 @@
import { HttpClient, HttpParams } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import {
PlayerChannelSettings,
PlayerChannelSettingsUpdate,
PlayerVideoSettings,
PlayerVideoSettingsUpdate
} from '@peertube/peertube-models'
import { catchError } from 'rxjs'
import { environment } from 'src/environments/environment'
import { VideoPasswordService } from '../shared-main/video/video-password.service'
@Injectable()
export class PlayerSettingsService {
static BASE_PLAYER_SETTINGS_URL = environment.apiUrl + '/api/v1/player-settings/'
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
getVideoSettings (options: {
videoId: string
videoPassword?: string
raw: boolean
}) {
const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId
let params = new HttpParams()
if (options.raw) params = params.set('raw', 'true')
return this.authHttp.get<PlayerVideoSettings>(path, { params, headers })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updateVideoSettings (options: {
videoId: string
settings: PlayerVideoSettingsUpdate
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'videos/' + options.videoId
return this.authHttp.put<PlayerVideoSettings>(path, options.settings)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
getChannelSettings (options: {
channelHandle: string
raw: boolean
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle
let params = new HttpParams()
if (options.raw) params = params.set('raw', 'true')
return this.authHttp.get<PlayerChannelSettings>(path, { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updateChannelSettings (options: {
channelHandle: string
settings: PlayerChannelSettingsUpdate
}) {
const path = PlayerSettingsService.BASE_PLAYER_SETTINGS_URL + 'video-channels/' + options.channelHandle
return this.authHttp.put<PlayerChannelSettings>(path, options.settings)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View file

@ -1,144 +1,98 @@
import { NgClass, NgIf } from '@angular/common' import { AfterViewInit, Component, inject } from '@angular/core'
import { AfterViewInit, Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { AuthService, HooksService, Notifier } from '@app/core' import { AuthService, HooksService, Notifier } from '@app/core'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model' import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { HttpStatusCode, PlayerChannelSettings, VideoChannelCreate } from '@peertube/peertube-models'
import { HttpStatusCode, VideoChannelCreate } from '@peertube/peertube-models'
import { of } from 'rxjs' import { of } from 'rxjs'
import { switchMap } from 'rxjs/operators' import { switchMap } from 'rxjs/operators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
import { VideoChannelEdit } from './video-channel-edit'
@Component({ @Component({
templateUrl: './video-channel-edit.component.html', template: `
styleUrls: [ './video-channel-edit.component.scss' ], <my-video-channel-edit
mode="create" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
`,
imports: [ imports: [
NgIf, VideoChannelEditComponent
FormsModule, ],
ReactiveFormsModule, providers: [
ActorBannerEditComponent, PlayerSettingsService
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
] ]
}) })
export class VideoChannelCreateComponent extends VideoChannelEdit implements OnInit, AfterViewInit { export class VideoChannelCreateComponent implements AfterViewInit {
protected formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private router = inject(Router) private router = inject(Router)
private videoChannelService = inject(VideoChannelService) private videoChannelService = inject(VideoChannelService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
private playerSettingsService = inject(PlayerSettingsService)
error: string error: string
videoChannel = new VideoChannel({}) channel = new VideoChannel({})
rawPlayerSettings: PlayerChannelSettings = {
private avatar: FormData theme: 'instance-default'
private banner: FormData
ngOnInit () {
this.buildForm({
'name': VIDEO_CHANNEL_NAME_VALIDATOR,
'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR
})
} }
ngAfterViewInit () { ngAfterViewInit () {
this.hooks.runAction('action:video-channel-create.init', 'video-channel') this.hooks.runAction('action:video-channel-create.init', 'video-channel')
} }
formValidated () { onFormValidated (output: FormValidatedOutput) {
this.error = undefined this.error = undefined
const body = this.form.value const channelCreate: VideoChannelCreate = {
const videoChannelCreate: VideoChannelCreate = { name: output.channel.name,
name: body.name, displayName: output.channel.displayName,
displayName: body['display-name'], description: output.channel.description,
description: body.description || null, support: output.channel.support
support: body.support || null
} }
this.videoChannelService.createVideoChannel(videoChannelCreate) this.videoChannelService.createVideoChannel(channelCreate)
.pipe( .pipe(
switchMap(() => this.uploadAvatar()), switchMap(() => {
switchMap(() => this.uploadBanner()) return this.playerSettingsService.updateChannelSettings({
channelHandle: output.channel.name,
settings: {
theme: output.playerSettings.theme
}
})
}),
switchMap(() => this.uploadAvatar(output.channel.name, output.avatar)),
switchMap(() => this.uploadBanner(output.channel.name, output.banner))
).subscribe({ ).subscribe({
next: () => { next: () => {
this.authService.refreshUserInformation() this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelCreate.displayName} created.`) this.notifier.success($localize`Video channel ${channelCreate.displayName} created.`)
this.router.navigate([ '/my-library', 'video-channels' ]) this.router.navigate([ '/my-library', 'video-channels' ])
}, },
error: err => { error: err => {
let message = err.message
if (err.status === HttpStatusCode.CONFLICT_409) { if (err.status === HttpStatusCode.CONFLICT_409) {
this.error = $localize`This name already exists on this platform.` message = $localize`Channel name "${channelCreate.name}" already exists on this platform.`
return
} }
this.error = err.message this.notifier.error(message)
} }
}) })
} }
onAvatarChange (formData: FormData) { private uploadAvatar (username: string, avatar?: FormData) {
this.avatar = formData if (!avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(username, avatar, 'avatar')
} }
onAvatarDelete () { private uploadBanner (username: string, banner?: FormData) {
this.avatar = null if (!banner) return of(undefined)
}
onBannerChange (formData: FormData) { return this.videoChannelService.changeVideoChannelImage(username, banner, 'banner')
this.banner = formData
}
onBannerDelete () {
this.banner = null
}
isCreation () {
return true
}
getFormButtonTitle () {
return $localize`Create your channel`
}
getUsername () {
return this.form.value.name
}
private uploadAvatar () {
if (!this.avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.avatar, 'avatar')
}
private uploadBanner () {
if (!this.banner) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(this.getUsername(), this.banner, 'banner')
} }
} }

View file

@ -1,11 +1,11 @@
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert> <my-alert *ngIf="error()" type="danger">{{ error() }}</my-alert>
<div class="pt-4"> <div class="pt-4">
<form (ngSubmit)="formValidated()" [formGroup]="form"> <form (ngSubmit)="onFormValidated()" [formGroup]="form">
<div class="pt-two-cols"> <!-- channel grid --> <div class="pt-two-cols"> <!-- channel grid -->
<div class="title-col"> <div class="title-col">
@if (isCreation()) { @if (mode() === 'create') {
<h2 i18n>NEW CHANNEL</h2> <h2 i18n>NEW CHANNEL</h2>
} @else { } @else {
<h2 i18n>UPDATE CHANNEL</h2> <h2 i18n>UPDATE CHANNEL</h2>
@ -14,40 +14,40 @@
<div class="content-col"> <div class="content-col">
<my-actor-banner-edit <my-actor-banner-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4" *ngIf="channel()" previewImage="true" class="d-block mb-4"
[bannerUrl]="videoChannel?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()" [bannerUrl]="channel()?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit> ></my-actor-banner-edit>
<my-actor-avatar-edit <my-actor-avatar-edit
*ngIf="videoChannel" class="d-block mb-4" actorType="channel" *ngIf="channel()" class="d-block mb-4" actorType="channel"
[displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars" [displayName]="channel().displayName" previewImage="true" [avatars]="channel().avatars"
[username]="!isCreation() && videoChannel.name" [subscribers]="!isCreation() && videoChannel.followersCount" [username]="mode() === 'update' && channel().name" [subscribers]="mode() === 'update' && channel().followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit> ></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()"> <div class="form-group" *ngIf="mode() === 'create'">
<label i18n for="name">Name</label> <label i18n for="name">Name</label>
<div class="input-group"> <div class="input-group">
<input <input
type="text" id="name" i18n-placeholder placeholder="Example: my_channel" type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control w-auto flex-grow-1 d-block" formControlName="name" [ngClass]="{ 'input-error': formErrors.name }" class="form-control w-auto flex-grow-1 d-block"
> >
<div class="input-group-text">&#64;{{ instanceHost }}</div> <div class="input-group-text">&#64;{{ instanceHost }}</div>
</div> </div>
<div *ngIf="formErrors['name']" class="form-error" role="alert"> <div *ngIf="formErrors.name" class="form-error" role="alert">
{{ formErrors['name'] }} {{ formErrors.name }}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="display-name">Display name</label> <label i18n for="displayName">Display name</label>
<input <input
type="text" id="display-name" class="form-control" type="text" id="displayName" class="form-control"
formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" formControlName="displayName" [ngClass]="{ 'input-error': formErrors.displayName }"
> >
<div *ngIf="formErrors['display-name']" class="form-error" role="alert"> <div *ngIf="formErrors.displayName" class="form-error" role="alert">
{{ formErrors['display-name'] }} {{ formErrors.displayName }}
</div> </div>
</div> </div>
@ -58,7 +58,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="description" formControlName="description" inputId="description" formControlName="description"
markdownType="enhanced" [formError]="formErrors['description']" withEmoji="true" withHtml="true" markdownType="enhanced" [formError]="formErrors.description" withEmoji="true" withHtml="true"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.description" class="form-error" role="alert"> <div *ngIf="formErrors.description" class="form-error" role="alert">
@ -75,7 +75,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="support" formControlName="support" inputId="support" formControlName="support"
markdownType="enhanced" [formError]="formErrors['support']" withEmoji="true" withHtml="true" markdownType="enhanced" [formError]="formErrors.support" withEmoji="true" withHtml="true"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -86,6 +86,13 @@
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group">
<label i18n for="playerTheme">Player Theme</label>
<my-select-player-theme formControlName="playerTheme" inputId="playerTheme" mode="channel">
</my-select-player-theme>
</div>
<input type="submit" class="peertube-button primary-button mt-4" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> <input type="submit" class="peertube-button primary-button mt-4" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</div> </div>
</div> </div>

View file

@ -1,16 +1,16 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
my-actor-banner-edit { my-actor-banner-edit {
max-width: 500px; max-width: 500px;
} }
input[type=text] { input[type="text"] {
@include peertube-input-text(340px); @include peertube-input-text(340px);
} }
input[type=submit] { input[type="submit"] {
@include margin-left(auto); @include margin-left(auto);
} }
@ -18,6 +18,8 @@ input[type=submit] {
max-width: 500px; max-width: 500px;
} }
.peertube-select-container { my-select-player-theme {
@include peertube-select-container(340px); display: block;
@include responsive-width(340px);
} }

View file

@ -0,0 +1,178 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input, OnInit, output } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { PlayerChannelSettings } from '@peertube/peertube-models'
import { BuildFormArgumentTyped, FormReactiveErrorsTyped, FormReactiveMessagesTyped } from '../form-validators/form-validator.model'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '../form-validators/video-channel-validators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component'
import { FormReactiveService } from '../shared-forms/form-reactive.service'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { SelectPlayerThemeComponent } from '../shared-forms/select/select-player-theme.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { AlertComponent } from '../shared-main/common/alert.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
type Form = {
name: FormControl<string>
displayName: FormControl<string>
description: FormControl<string>
support: FormControl<string>
playerTheme: FormControl<PlayerChannelSettings['theme']>
bulkVideosSupportUpdate: FormControl<boolean>
}
export type FormValidatedOutput = {
avatar: FormData
banner: FormData
playerSettings: {
theme: PlayerChannelSettings['theme']
}
channel: {
name: string
displayName: string
description: string
support: string
bulkVideosSupportUpdate: boolean
}
}
@Component({
selector: 'my-video-channel-edit',
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent,
SelectPlayerThemeComponent
]
})
export class VideoChannelEditComponent implements OnInit {
private formReactiveService = inject(FormReactiveService)
readonly mode = input.required<'create' | 'update'>()
readonly channel = input.required<VideoChannel>()
readonly rawPlayerSettings = input.required<PlayerChannelSettings>()
readonly error = input<string>()
readonly formValidated = output<FormValidatedOutput>()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
private avatar: FormData
private banner: FormData
private oldSupportField: string
ngOnInit () {
this.buildForm()
this.oldSupportField = this.channel().support
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
name: this.mode() === 'create'
? VIDEO_CHANNEL_NAME_VALIDATOR
: null,
displayName: VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
support: VIDEO_CHANNEL_SUPPORT_VALIDATOR,
bulkVideosSupportUpdate: null,
playerTheme: null
}
const defaultValues = {
displayName: this.channel().displayName,
description: this.channel().description,
support: this.channel().support,
playerTheme: this.rawPlayerSettings().theme
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getFormButtonTitle () {
if (this.mode() === 'update') {
return $localize`Update ${this.channel().name}`
}
return $localize`Create your channel`
}
onAvatarChange (formData: FormData) {
this.avatar = formData
}
onAvatarDelete () {
this.avatar = null
}
onBannerChange (formData: FormData) {
this.banner = formData
}
onBannerDelete () {
this.banner = null
}
get instanceHost () {
return window.location.host
}
isBulkUpdateVideosDisplayed () {
if (this.mode() === 'create') return false
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value.support
}
onFormValidated () {
const body = this.form.value
this.formValidated.emit({
avatar: this.avatar,
banner: this.banner,
playerSettings: {
theme: body.playerTheme
},
channel: {
name: body.name,
displayName: body.displayName,
description: body.description || null,
support: body.support || null,
bulkVideosSupportUpdate: this.mode() === 'update'
? body.bulkVideosSupportUpdate || false
: undefined
}
})
}
}

View file

@ -1,18 +0,0 @@
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
export abstract class VideoChannelEdit extends FormReactive {
videoChannel: VideoChannel
abstract isCreation (): boolean
abstract getFormButtonTitle (): string
get instanceHost () {
return window.location.host
}
// Should be implemented by the child
isBulkUpdateVideosDisplayed () {
return false
}
}

View file

@ -1,92 +1,65 @@
import { NgClass, NgIf } from '@angular/common' import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
import { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { AuthService, HooksService, Notifier, RedirectService } from '@app/core' import { AuthService, HooksService, Notifier, RedirectService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers' import { genericUploadErrorHandler } from '@app/helpers'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service' import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { shallowCopy } from '@peertube/peertube-core-utils' import { shallowCopy } from '@peertube/peertube-core-utils'
import { VideoChannelUpdate } from '@peertube/peertube-models' import { PlayerChannelSettings, VideoChannelUpdate } from '@peertube/peertube-models'
import { Subscription } from 'rxjs' import { catchError, forkJoin, Subscription, switchMap, tap, throwError } from 'rxjs'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component' import { VideoChannel } from '../shared-main/channel/video-channel.model'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-edit.component' import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { MarkdownTextareaComponent } from '../shared-forms/markdown-textarea.component' import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
import { PeertubeCheckboxComponent } from '../shared-forms/peertube-checkbox.component'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { MarkdownHintComponent } from '../shared-main/text/markdown-hint.component'
import { VideoChannelEdit } from './video-channel-edit'
@Component({ @Component({
selector: 'my-video-channel-update', selector: 'my-video-channel-update',
templateUrl: './video-channel-edit.component.html', template: `
styleUrls: [ './video-channel-edit.component.scss' ], @if (channel && rawPlayerSettings) {
<my-video-channel-edit
mode="update" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
}
`,
imports: [ imports: [
NgIf, VideoChannelEditComponent
FormsModule, ],
ReactiveFormsModule, providers: [
ActorBannerEditComponent, PlayerSettingsService
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
] ]
}) })
export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnInit, AfterViewInit, OnDestroy { export class VideoChannelUpdateComponent implements OnInit, AfterViewInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
private authService = inject(AuthService) private authService = inject(AuthService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private videoChannelService = inject(VideoChannelService) private videoChannelService = inject(VideoChannelService)
private playerSettingsService = inject(PlayerSettingsService)
private redirectService = inject(RedirectService) private redirectService = inject(RedirectService)
private hooks = inject(HooksService) private hooks = inject(HooksService)
channel: VideoChannel
rawPlayerSettings: PlayerChannelSettings
error: string error: string
private paramsSub: Subscription private paramsSub: Subscription
private oldSupportField: string
ngOnInit () { ngOnInit () {
this.buildForm({
'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
'description': VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
'support': VIDEO_CHANNEL_SUPPORT_VALIDATOR,
'bulkVideosSupportUpdate': null
})
this.paramsSub = this.route.params.subscribe(routeParams => { this.paramsSub = this.route.params.subscribe(routeParams => {
const videoChannelName = routeParams['videoChannelName'] const videoChannelName = routeParams['videoChannelName']
this.videoChannelService.getVideoChannel(videoChannelName) forkJoin([
.subscribe({ this.videoChannelService.getVideoChannel(videoChannelName),
next: videoChannelToUpdate => { this.playerSettingsService.getChannelSettings({ channelHandle: videoChannelName, raw: true })
this.videoChannel = videoChannelToUpdate ]).subscribe({
next: ([ channel, rawPlayerSettings ]) => {
this.channel = channel
this.rawPlayerSettings = rawPlayerSettings
this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.videoChannel }) this.hooks.runAction('action:video-channel-update.video-channel.loaded', 'video-channel', { videoChannel: this.channel })
},
this.oldSupportField = videoChannelToUpdate.support error: err => this.notifier.error(err.message)
})
this.form.patchValue({
'display-name': videoChannelToUpdate.displayName,
'description': videoChannelToUpdate.description,
'support': videoChannelToUpdate.support
})
},
error: err => {
this.error = err.message
}
})
}) })
} }
@ -98,112 +71,84 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
if (this.paramsSub) this.paramsSub.unsubscribe() if (this.paramsSub) this.paramsSub.unsubscribe()
} }
formValidated () { onFormValidated (output: FormValidatedOutput) {
this.error = undefined this.error = undefined
const body = this.form.value
const videoChannelUpdate: VideoChannelUpdate = { const videoChannelUpdate: VideoChannelUpdate = {
displayName: body['display-name'], displayName: output.channel.displayName,
description: body.description || null, description: output.channel.description,
support: body.support || null, support: output.channel.support,
bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false bulkVideosSupportUpdate: output.channel.bulkVideosSupportUpdate
} }
this.videoChannelService.updateVideoChannel(this.videoChannel.name, videoChannelUpdate) this.videoChannelService.updateVideoChannel(this.channel.name, videoChannelUpdate)
.pipe(
switchMap(() => {
return this.playerSettingsService.updateChannelSettings({
channelHandle: this.channel.name,
settings: {
theme: output.playerSettings.theme
}
})
}),
switchMap(() => this.updateOrDeleteAvatar(output.avatar)),
switchMap(() => this.updateOrDeleteBanner(output.banner))
)
.subscribe({ .subscribe({
next: () => { next: () => {
// So my-actor-avatar component detects changes
this.channel = shallowCopy(this.channel)
this.authService.refreshUserInformation() this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`) this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`)
this.redirectService.redirectToPreviousRoute('/c/' + this.videoChannel.name) this.redirectService.redirectToPreviousRoute('/c/' + this.channel.name)
},
error: err => {
this.error = err.message
}
})
}
onAvatarChange (formData: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'avatar')
.subscribe({
next: data => {
this.notifier.success($localize`Avatar changed.`)
this.videoChannel.updateAvatar(data.avatars)
// So my-actor-avatar component detects changes
this.videoChannel = shallowCopy(this.videoChannel)
},
error: (err: HttpErrorResponse) =>
genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
})
})
}
onAvatarDelete () {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'avatar')
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.videoChannel.resetAvatar()
// So my-actor-avatar component detects changes
this.videoChannel = shallowCopy(this.videoChannel)
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
onBannerChange (formData: FormData) { private updateOrDeleteAvatar (avatar: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'banner') if (!avatar) {
.subscribe({ return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'avatar')
next: data => { .pipe(tap(() => this.channel.resetAvatar()))
this.notifier.success($localize`Banner changed.`) }
this.videoChannel.updateBanner(data.banners) return this.videoChannelService.changeVideoChannelImage(this.channel.name, avatar, 'avatar')
}, .pipe(
tap(data => this.channel.updateAvatar(data.avatars)),
error: (err: HttpErrorResponse) => catchError(err =>
genericUploadErrorHandler({ throwError(() => {
err, return new Error(genericUploadErrorHandler({
name: $localize`banner`, err,
notifier: this.notifier name: $localize`avatar`,
notifier: this.notifier
}))
}) })
}) )
)
} }
onBannerDelete () { private updateOrDeleteBanner (banner: FormData) {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'banner') if (!banner) {
.subscribe({ return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'banner')
next: () => { .pipe(tap(() => this.channel.resetBanner()))
this.notifier.success($localize`Banner deleted.`) }
this.videoChannel.resetBanner() return this.videoChannelService.changeVideoChannelImage(this.channel.name, banner, 'banner')
}, .pipe(
tap(data => this.channel.updateBanner(data.banners)),
error: err => this.notifier.error(err.message) catchError(err =>
}) throwError(() => {
} return new Error(genericUploadErrorHandler({
err,
isCreation () { name: $localize`banner`,
return false notifier: this.notifier
} }))
})
getFormButtonTitle () { )
return $localize`Update ${this.videoChannel?.name}` )
}
isBulkUpdateVideosDisplayed () {
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value['support']
} }
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more