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/toml-0.6.2.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
## 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
### 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",
"maximumWarning": "6kb",
"maximumError": "120kb"
"maximumError": "140kb"
}
],
"fileReplacements": [

View file

@ -2,25 +2,19 @@ import { NSFWPolicyType } from '@peertube/peertube-models'
import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information' | 'live') {
const waitTitles = {
'instance-homepage': 'INSTANCE HOMEPAGE',
'basic-configuration': 'APPEARANCE',
'instance-information': 'INSTANCE',
'live': 'LIVE'
async navigateTo (page: 'information' | 'live' | 'general' | 'homepage') {
const url = '/admin/settings/config/' + page
const currentUrl = await browser.getUrl()
if (!currentUrl.endsWith(url)) {
await go(url)
}
const url = '/admin/settings/config/edit-custom#' + tab
if (await browser.getUrl() !== url) {
await go('/admin/settings/config/edit-custom#' + tab)
}
await $('h2=' + waitTitles[tab]).waitForDisplayed()
await $('a.active[href="' + url + '"]').waitForDisplayed()
}
async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('instance-information')
await this.navigateTo('information')
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
@ -32,25 +26,25 @@ export class AdminConfigPage {
}
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) {
await this.navigateTo('basic-configuration')
await this.navigateTo('general')
return setCheckboxEnabled('signupEnabled', enabled)
}
async toggleSignupApproval (required: boolean) {
await this.navigateTo('basic-configuration')
await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresApproval', required)
}
async toggleSignupEmailVerification (required: boolean) {
await this.navigateTo('basic-configuration')
await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresEmailVerification', required)
}
@ -62,11 +56,18 @@ export class AdminConfigPage {
}
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 browserSleep(1000)
await button.waitForClickable({ reverse: true })
}
}

View file

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

View file

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

View file

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

View file

@ -76,7 +76,7 @@ export abstract class VideoManage {
await input.waitForClickable()
await input.click()
const nextMonth = $('.p-datepicker-next')
const nextMonth = $('.p-datepicker-next-button')
await nextMonth.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') {
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.click()
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po'
import { PlayerPage } from '../po/player.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) {
await playerPage.playAndPauseVideo(false, 2)
@ -22,9 +22,7 @@ describe('Private videos all workflow', () => {
loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage()
if (!isMobileDevice()) {
await browser.maximizeWindow()
}
await prepareWebBrowser()
})
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 { VideoUpdatePage } from '../po/video-update.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 () {
if (isMobileDevice() || isSafari()) {
@ -53,9 +53,7 @@ describe('Videos all workflow', () => {
playerPage = new PlayerPage()
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
if (!isMobileDevice()) {
await browser.maximizeWindow()
}
await prepareWebBrowser()
})
it('Should log in', async () => {

View file

@ -1,7 +1,7 @@
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, waitServerUp } from '../utils'
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Custom server defaults', () => {
let videoPublishPage: VideoPublishPage
@ -15,7 +15,7 @@ describe('Custom server defaults', () => {
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
await prepareWebBrowser()
})
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 { VideoSearchPage } from '../po/video-search.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', () => {
let videoListPage: VideoListPage
@ -102,6 +102,8 @@ describe('NSFW', () => {
for (const video of videos) {
await videoSearchPage.search(video)
await browser.saveScreenshot(getScreenshotPath('before-test.png'))
await checkVideo({ policy, videoName: video, nsfwTooltip })
}
}
@ -153,7 +155,7 @@ describe('NSFW', () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow()
await prepareWebBrowser()
})
describe('Preparation', function () {
@ -265,10 +267,10 @@ describe('NSFW', () => {
expect(await moreButton.isDisplayed()).toBeTruthy()
await moreButton.click()
await playerPage.getNSFWMoreContent().waitForDisplayed()
await playerPage.getNSFWDetailsContent().waitForDisplayed()
const moreContent = await playerPage.getNSFWMoreContent().getText()
expect(moreContent).toContain('Violence')
const moreContent = await playerPage.getNSFWDetailsContent().getText()
expect(moreContent).toContain('Potentially violent content')
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 { VideoPublishPage } from '../po/video-publish.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', () => {
let videoPublishPage: VideoPublishPage
@ -21,7 +21,7 @@ describe('Player settings', () => {
myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow()
await prepareWebBrowser()
})
describe('P2P', function () {

View file

@ -1,7 +1,7 @@
import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.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', () => {
let videoPublishPage: VideoPublishPage
@ -28,7 +28,7 @@ describe('Plugins', () => {
videoPublishPage = new VideoPublishPage()
adminPluginPage = new AdminPluginPage()
await browser.maximizeWindow()
await prepareWebBrowser()
})
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 { VideoPublishPage } from '../po/video-publish.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 () {
let videoPublishPage: VideoPublishPage
@ -18,7 +18,7 @@ describe('Publish live', function () {
adminConfigPage = new AdminConfigPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow()
await prepareWebBrowser()
await loginPage.loginAsRootUser()
})

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po'
import { VideoPublishPage } from '../po/video-publish.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', () => {
let videoPublishPage: VideoPublishPage
@ -50,7 +50,7 @@ describe('Password protected videos', () => {
playerPage = new PlayerPage()
myAccountPage = new MyAccountPage()
await browser.maximizeWindow()
await prepareWebBrowser()
})
describe('Owner', function () {

View file

@ -1,29 +1,31 @@
async function browserSleep (amount: number) {
export async function browserSleep (amount: number) {
await browser.pause(amount)
}
function isMobileDevice () {
// ---------------------------------------------------------------------------
export function isMobileDevice () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' || platformName === 'ios'
}
function isAndroid () {
export function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android'
}
function isSafari () {
export function isSafari () {
return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari'
browser.capabilities['browserName'].toLowerCase() === 'safari'
}
function isIOS () {
export function isIOS () {
return isMobileDevice() && isSafari()
}
async function go (url: string) {
export async function go (url: string) {
await browser.url(url)
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 go('/')
await browserSleep(500)
@ -41,13 +56,3 @@ async function waitServerUp () {
return $('<my-app>').isDisplayed()
}, { 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) {
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForExist()
await wrapper.scrollIntoView({ block: 'center' })
await wrapper.waitForClickable()
await wrapper.click()

View file

@ -95,18 +95,18 @@ module.exports = {
{
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',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '14' })
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 12', osVersion: '14' })
},
{
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: [
[
'browserstack', { browserstackLocal: true }
'browserstack',
{ browserstackLocal: true }
]
],
@ -174,6 +175,5 @@ module.exports = {
onPrepare: onBrowserStackPrepare,
onComplete: onBrowserStackComplete
} as WebdriverIO.Config
}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,26 +7,32 @@
</div>
<div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<div class="form-group" formGroupName="theme">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
<ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
<div class="form-group" formGroupName="miniature">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</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>
</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 { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
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 { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
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 { AlertComponent } from '../../../shared/shared-main/common/alert.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')
@ -65,6 +66,12 @@ type Form = {
inputBorderRadius: FormControl<string>
}>
}>
defaults: FormGroup<{
player: FormGroup<{
theme: FormControl<PlayerTheme>
}>
}>
}
type FieldType = 'color' | 'radius'
@ -84,7 +91,8 @@ type FieldType = 'color' | 'radius'
SelectOptionsComponent,
HelpComponent,
PeertubeCheckboxComponent,
SelectCustomValueComponent
SelectCustomValueComponent,
SelectPlayerThemeComponent
]
})
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
@ -108,6 +116,7 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
}[] = []
availableThemes: SelectOptionsItem[]
availablePlayerThemes: SelectOptionsItem<PlayerTheme>[] = []
private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig
@ -164,6 +173,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
...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.subscribeToCustomizationChanges()
@ -265,6 +279,11 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
headerBackgroundColor: null,
inputBorderRadius: null
}
},
defaults: {
player: {
theme: null
}
}
}

View file

@ -7,7 +7,6 @@
</div>
<div class="content-col">
<div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -43,13 +42,14 @@
</div>
<ng-container formGroupName="client">
<ng-container formGroupName="menu">
<ng-container formGroupName="login">
<div class="form-group">
<my-peertube-checkbox
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth"
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
inputName="clientMenuLoginRedirectOnSingleExternalAuth"
formControlName="redirectOnSingleExternalAuth"
i18n-labelText
labelText="Redirect users on single external auth when users click on the login button in menu"
>
<ng-container ngProjectAs="description">
@if (countExternalAuth() === 0) {
@ -58,12 +58,11 @@
<span i18n>⚠️ You have multiple external auth plugins enabled</span>
}
</ng-container>
</my-peertube-checkbox>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
@ -76,20 +75,22 @@
</div>
<div class="content-col">
<ng-container formGroupName="broadcastMessage">
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageEnabled" formControlName="enabled"
i18n-labelText labelText="Enable broadcast message"
inputName="broadcastMessageEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable broadcast message"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="broadcastMessageDismissable" formControlName="dismissable"
i18n-labelText labelText="Allow users to dismiss the broadcast message "
inputName="broadcastMessageDismissable"
formControlName="dismissable"
i18n-labelText
labelText="Allow users to dismiss the broadcast message "
></my-peertube-checkbox>
</div>
@ -111,31 +112,28 @@
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
<my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message"
[formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html"
inputId="broadcastMessageMessage"
formControlName="message"
[formError]="formErrors.broadcastMessage.message"
markdownType="to-unsafe-html"
></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div>
</ng-container>
</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">
<h2 i18n>NEW USERS</h2>
</div>
<div class="content-col">
<ng-container formGroupName="signup">
<div class="form-group">
<my-peertube-checkbox
inputName="signupEnabled" formControlName="enabled"
i18n-labelText labelText="Enable Signup"
>
<my-peertube-checkbox inputName="signupEnabled" formControlName="enabled" i18n-labelText labelText="Enable Signup">
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -144,27 +142,37 @@
<ng-container ngProjectAs="extra">
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval" formControlName="requiresApproval"
i18n-labelText labelText="Signup requires approval by moderators"
<my-peertube-checkbox
[ngClass]="getDisabledSignupClass()"
inputName="signupRequiresApproval"
formControlName="requiresApproval"
i18n-labelText
labelText="Signup requires approval by moderators"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
<my-peertube-checkbox
[ngClass]="getDisabledSignupClass()"
inputName="signupRequiresEmailVerification"
formControlName="requiresEmailVerification"
i18n-labelText
labelText="Signup requires email verification"
></my-peertube-checkbox>
</div>
<div [ngClass]="getDisabledSignupClass()">
<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">
<input
type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }"
type="number"
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>
</div>
@ -179,8 +187,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
type="number"
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>
</div>
@ -201,7 +213,9 @@
inputId="userVideoQuota"
[items]="getVideoQuotaOptions()"
formControlName="videoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -218,7 +232,9 @@
inputId="userVideoQuotaDaily"
[items]="getVideoQuotaDailyOptions()"
formControlName="videoQuotaDaily"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -228,15 +244,16 @@
<ng-container formGroupName="history">
<ng-container formGroupName="videos">
<my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically enable video history for a new user"
inputName="videosHistoryEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically enable video history for a new user"
>
</my-peertube-checkbox>
</ng-container>
</ng-container>
</div>
</ng-container>
</div>
</div>
@ -246,11 +263,8 @@
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="videos">
<div class="form-group">
<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>
@ -265,39 +279,46 @@
<div class="form-group" formGroupName="http">
<my-peertube-checkbox
inputName="importVideosHttpEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)"
inputName="importVideosHttpEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow import with HTTP URL (e.g. YouTube)"
>
<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>
</my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="torrent">
<my-peertube-checkbox
inputName="importVideosTorrentEnabled" formControlName="enabled"
i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
inputName="importVideosTorrentEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow import with a torrent file or a magnet URI"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container>
</my-peertube-checkbox>
<ng-container ngProjectAs="description">
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="videoChannelSynchronization">
<div class="form-group">
<my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube"
inputName="importSynchronizationEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow channel synchronization with channel of other platforms like YouTube"
>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
<ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()">
⛔ You need to allow import with HTTP URL to be able to activate this feature.
</span>
</ng-container>
</my-peertube-checkbox>
</div>
@ -306,16 +327,21 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
type="number"
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>
</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>
</ng-container>
</ng-container>
</div>
</div>
@ -326,22 +352,21 @@
</div>
<div class="content-col">
<ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos">
<ng-container formGroupName="ofUsers">
<div class="form-group">
<my-peertube-checkbox
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Block new videos automatically"
inputName="autoBlacklistVideosOfUsersEnabled"
formControlName="enabled"
i18n-labelText
labelText="Block new videos automatically"
>
<ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container>
</my-peertube-checkbox>
<ng-container ngProjectAs="description">
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
@ -350,8 +375,10 @@
<ng-container formGroupName="update">
<div class="form-group">
<my-peertube-checkbox
inputName="videoFileUpdateEnabled" formControlName="enabled"
i18n-labelText labelText="Allow users to upload a new version of their video"
inputName="videoFileUpdateEnabled"
formControlName="enabled"
i18n-labelText
labelText="Allow users to upload a new version of their video"
>
</my-peertube-checkbox>
</div>
@ -360,10 +387,7 @@
<ng-container formGroupName="storyboards">
<div class="form-group">
<my-peertube-checkbox
inputName="storyboardsEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video storyboards"
>
<my-peertube-checkbox inputName="storyboardsEnabled" formControlName="enabled" i18n-labelText labelText="Enable video storyboards">
<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>
</ng-container>
@ -373,24 +397,24 @@
<ng-container formGroupName="videoTranscription">
<div class="form-group">
<my-peertube-checkbox
inputName="videoTranscriptionEnabled" formControlName="enabled"
i18n-labelText labelText="Enable video transcription"
>
<my-peertube-checkbox inputName="videoTranscriptionEnabled" formControlName="enabled" i18n-labelText labelText="Enable video transcription">
<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 ngProjectAs="extra">
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
<my-peertube-checkbox
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled"
i18n-labelText labelText="Enable remote runners for transcription"
inputName="videoTranscriptionRemoteRunnersEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable remote runners for transcription"
>
<ng-container ngProjectAs="description">
<span i18n>
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks.
Remote runners has to register on your instance first.
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks. Remote runners has to
register on your instance first.
</span>
</ng-container>
</my-peertube-checkbox>
@ -402,7 +426,6 @@
<ng-container formGroupName="defaults">
<ng-container formGroupName="publish">
<div class="form-group">
<label i18n for="defaultsPublishPrivacy">Default video privacy</label>
@ -442,8 +465,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
type="number"
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>
</div>
@ -462,15 +489,16 @@
</div>
<div class="content-col">
<ng-container formGroupName="videoComments">
<div class="form-group">
<my-peertube-checkbox
inputName="videoCommentsAcceptRemoteComments" formControlName="acceptRemoteComments"
i18n-labelText labelText="Accept comments made on remote platforms"
inputName="videoCommentsAcceptRemoteComments"
formControlName="acceptRemoteComments"
i18n-labelText
labelText="Accept comments made on remote platforms"
>
<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>
</my-peertube-checkbox>
</div>
@ -480,22 +508,25 @@
<ng-container formGroupName="channels">
<div class="form-group">
<my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow channels of your platform"
inputName="followersChannelsEnabled"
formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow channels of your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow your platform"
inputName="followersInstanceEnabled"
formControlName="enabled"
i18n-labelText
labelText="Remote actors can follow your platform"
>
<ng-container ngProjectAs="description">
<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">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform"
inputName="followersInstanceManualApproval"
formControlName="manualApproval"
i18n-labelText
labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox>
</div>
</ng-container>
@ -514,12 +547,13 @@
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform"
inputName="followingsInstanceAutoFollowBackEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically follow back followers that follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
@ -531,14 +565,21 @@
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow platforms of a public index"
inputName="followingsInstanceAutoFollowIndexEnabled"
formControlName="enabled"
i18n-labelText
labelText="Automatically follow platforms of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<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>
</ng-container>
@ -546,19 +587,22 @@
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
type="text"
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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
@ -569,27 +613,34 @@
<div class="content-col">
<ng-container formGroupName="defaults">
<ng-container formGroupName="player">
<div class="form-group" formGroupName="player">
<my-peertube-checkbox
inputName="defaultsPlayerAutoplay" formControlName="autoPlay"
i18n-labelText labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="defaultsPlayerAutoplay"
formControlName="autoPlay"
i18n-labelText
labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="p2p">
<div class="form-group" formGroupName="webapp">
<my-peertube-checkbox
inputName="defaultsP2PWebappEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default on your platform"
inputName="defaultsP2PWebappEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default on your platform"
></my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="embed">
<my-peertube-checkbox
inputName="defaultsP2PEmbedEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default for videos embedded on external websites"
inputName="defaultsP2PEmbedEnabled"
formControlName="enabled"
i18n-labelText
labelText="Enable P2P streaming by default for videos embedded on external websites"
></my-peertube-checkbox>
</div>
</ng-container>
@ -603,14 +654,14 @@
</div>
<div class="content-col">
<ng-container formGroupName="search">
<ng-container formGroupName="remoteUri">
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriUsers" formControlName="users"
i18n-labelText labelText="Allow users to do remote URI/handle search"
inputName="searchRemoteUriUsers"
formControlName="users"
i18n-labelText
labelText="Allow users to do remote URI/handle search"
>
<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>
@ -620,23 +671,21 @@
<div class="form-group">
<my-peertube-checkbox
inputName="searchRemoteUriAnonymous" formControlName="anonymous"
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
inputName="searchRemoteUriAnonymous"
formControlName="anonymous"
i18n-labelText
labelText="Allow anonymous to do remote URI/handle search"
>
<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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="searchIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="searchIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Enable global search"
>
<my-peertube-checkbox inputName="searchIndexEnabled" formControlName="enabled" i18n-labelText labelText="Enable global search">
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div>
</ng-container>
@ -646,43 +695,48 @@
<label i18n for="searchIndexUrl">Search index URL</label>
<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>
<input
type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
type="text"
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>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
i18n-labelText labelText="Disable local search in search bar"
<my-peertube-checkbox
[ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexDisableLocalSearch"
formControlName="disableLocalSearch"
i18n-labelText
labelText="Disable local search in search bar"
></my-peertube-checkbox>
</div>
<div class="mt-3">
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
i18n-labelText labelText="Search bar uses the global search index by default"
<my-peertube-checkbox
[ngClass]="getDisabledSearchIndexClass()"
inputName="searchIndexIsDefaultSearch"
formControlName="isDefaultSearch"
i18n-labelText
labelText="Search bar uses the global search index by default"
>
<ng-container ngProjectAs="description">
<span i18n>Otherwise, the local search will be used by default</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
@ -692,14 +746,10 @@
</div>
<div class="content-col">
<ng-container formGroupName="import">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="importUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to import a data archive"
>
<my-peertube-checkbox inputName="importUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to import a data archive">
<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 (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 formGroupName="export">
<ng-container formGroupName="users">
<div class="form-group">
<my-peertube-checkbox
inputName="exportUsersEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to export their data"
>
<my-peertube-checkbox inputName="exportUsersEnabled" formControlName="enabled" i18n-labelText labelText="Allow your users to export their data">
<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>
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<label i18n id="exportUsersMaxUserVideoQuota" for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
@ -734,7 +778,9 @@
inputId="exportUsersMaxUserVideoQuota"
[items]="exportMaxUserVideoQuotaOptions"
formControlName="maxUserVideoQuota"
i18n-inputSuffix inputSuffix="bytes" inputType="number"
i18n-inputSuffix
inputSuffix="bytes"
inputType="number"
[clearable]="false"
></my-select-custom-value>
@ -744,20 +790,21 @@
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
<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 *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
</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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
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 { pairwise } from 'rxjs/operators'
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` }
]
this.liveResolutions = this.adminConfigService.transcodingResolutionOptions
this.liveResolutions = this.adminConfigService.getTranscodingOptions('live')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(
this.server.getHTMLConfig().live.transcoding.availableProfiles
)
@ -143,7 +143,7 @@ export class AdminConfigLiveComponent implements OnInit, OnDestroy, CanComponent
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: this.adminConfigService.buildFormResolutions(),
resolutions: this.adminConfigService.buildFormResolutions('live'),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
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">
<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.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.adminConfigService.transcodingResolutionOptions
this.resolutions = this.adminConfigService.getTranscodingOptions('vod')
this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles)
@ -156,7 +156,7 @@ export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentD
max: TRANSCODING_MAX_FPS_VALIDATOR
},
resolutions: this.adminConfigService.buildFormResolutions(),
resolutions: this.adminConfigService.buildFormResolutions('vod'),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {

View file

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

View file

@ -1,8 +1,17 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
input[type=text],
input[type=password] {
input[type="text"],
input[type="password"] {
@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"
>
<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 #captionRight>

View file

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

View file

@ -1,15 +1,13 @@
@if (user.videoChannels.length > 1) {
<div class="form-group">
<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">
<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="channel-filters">
@for (channel of channels; track channel.id) {
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onChannelFilter(channel)"></my-channel-toggle>
}
</div>
<div class="channel-filters">
@for (channel of channels; track channel.id) {
<my-channel-toggle [channel]="channel" [(ngModel)]="channel.selected" (ngModelChange)="onChannelFilter(channel)"></my-channel-toggle>
}
</div>
}
</div>
<my-table
#table
@ -56,7 +54,7 @@
</td>
<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 *ngIf="table.isColumnDisplayed('name')">

View file

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

View file

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

View file

@ -3,7 +3,7 @@
<div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<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>

View file

@ -1,6 +1,7 @@
import { Routes } from '@angular/router'
import { AbuseService } from '@app/shared/shared-moderation/abuse.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 { SearchService } from '@app/shared/shared-search/search.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 { LiveVideoService } from '@app/shared/shared-video-live/live-video.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 { VideoRecommendationService } from './shared'
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 [
{
@ -30,7 +31,8 @@ export default [
AbuseService,
UserAdminService,
BulkService,
VideoStateMessageService
VideoStateMessageService,
PlayerSettingsService
],
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 { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
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 {
HTMLServerConfig,
HttpStatusCode,
LiveVideo,
PeerTubeProblemDocument,
PlayerMode,
PlayerTheme,
PlayerVideoSettings,
ServerErrorCode,
Storyboard,
VideoCaption,
@ -51,7 +55,6 @@ import {
PeerTubePlayer,
PeerTubePlayerConstructorOptions,
PeerTubePlayerLoadOptions,
PlayerMode,
videojs,
VideojsPlayer
} from '@peertube/player'
@ -79,6 +82,7 @@ const debugLogger = debug('peertube:watch:VideoWatchComponent')
type URLOptions = {
playerMode: PlayerMode
playerTheme?: PlayerTheme
startTime: number | string
stopTime: number | string
@ -138,6 +142,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private zone = inject(NgZone)
private videoCaptionService = inject(VideoCaptionService)
private videoChapterService = inject(VideoChapterService)
private playerSettingsService = inject(PlayerSettingsService)
private hotkeysService = inject(HotkeysService)
private hooks = inject(HooksService)
private pluginService = inject(PluginService)
@ -161,6 +166,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: LiveVideo
videoPassword: string
storyboards: Storyboard[] = []
playerSettings: PlayerVideoSettings
playlistPosition: number
playlist: VideoPlaylist = null
@ -372,9 +378,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoCaptionService.listCaptions(videoId, videoPassword),
this.videoChapterService.getChapters({ videoId, videoPassword }),
this.videoService.getStoryboards(videoId, videoPassword),
this.playerSettingsService.getVideoSettings({ videoId, videoPassword, raw: false }),
this.userService.getAnonymousOrLoggedUser()
]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => {
next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, playerSettings, loggedInOrAnonymousUser ]) => {
this.onVideoFetched({
video,
live,
@ -383,6 +390,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards,
videoFileToken,
videoPassword,
playerSettings,
loggedInOrAnonymousUser,
forceAutoplay
}).catch(err => {
@ -489,6 +497,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards: Storyboard[]
videoFileToken: string
videoPassword: string
playerSettings: PlayerVideoSettings
loggedInOrAnonymousUser: User
forceAutoplay: boolean
@ -501,6 +510,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
storyboards,
videoFileToken,
videoPassword,
playerSettings,
loggedInOrAnonymousUser,
forceAutoplay
} = options
@ -514,6 +524,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoFileToken = videoFileToken
this.videoPassword = videoPassword
this.storyboards = storyboards
this.playerSettings = playerSettings
// Re init attributes
this.remoteServerDown = false
@ -577,6 +588,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword,
playerSettings: this.playerSettings,
urlOptions: this.getUrlOptions(),
loggedInOrAnonymousUser,
forceAutoplay,
@ -725,6 +737,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: VideoCaption[]
videoChapters: VideoChapter[]
storyboards: Storyboard[]
playerSettings: PlayerVideoSettings
videoFileToken: string
videoPassword: string
@ -745,7 +758,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoPassword,
urlOptions,
loggedInOrAnonymousUser,
forceAutoplay
forceAutoplay,
playerSettings
} = options
let mode: PlayerMode
@ -814,6 +828,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return {
mode,
theme: urlOptions.playerTheme || playerSettings.theme as PlayerTheme,
autoplay: this.isAutoplay(video, loggedInOrAnonymousUser),
forceAutoplay,
@ -1032,6 +1047,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
subtitle: queryParams.subtitle,
playerMode: queryParams.mode,
playerTheme: queryParams.playerTheme,
playbackRate: queryParams.playbackRate,
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 { VideoManageComponent } from './video-manage.component'
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 [
{
@ -16,12 +18,14 @@ export default [
canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ],
providers: [
VideoManageController,
VideoManageResolver,
LiveVideoService,
I18nPrimengCalendarService,
VideoUploadService,
VideoStudioService,
VideoStateMessageService
VideoStateMessageService,
PlayerSettingsService
],
resolve: {
resolverData: VideoManageResolver

View file

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

View file

@ -3,7 +3,6 @@ import { Component, HostListener, inject, OnDestroy, OnInit } from '@angular/cor
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
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 { VideoManageController } from '../shared-manage/video-manage-controller.service'
import { VideoManageResolverData } from './video-manage.resolver'
@ -16,8 +15,7 @@ import { VideoManageResolverData } from './video-manage.resolver'
FormsModule,
ReactiveFormsModule,
VideoManageContainerComponent
],
providers: [ VideoManageController ]
]
})
export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute)
@ -29,18 +27,9 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
isUpdatingVideo = false
loaded = false
async ngOnInit () {
ngOnInit () {
const data = this.route.snapshot.data.resolverData as VideoManageResolverData
const { video, userChannels, captions, chapters, videoSource, live, videoPasswords, userQuota, privacies } = data
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
videoPasswords: videoPasswords.map(p => p.password)
})
const { userChannels, userQuota, privacies, videoEdit } = data
this.manageController.setStore({
videoEdit,
@ -50,8 +39,6 @@ export class VideoManageComponent implements OnInit, OnDestroy, CanComponentDeac
})
this.manageController.setConfig({ manageType: 'update', serverConfig: this.serverService.getHTMLConfig() })
this.loaded = true
}
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 {
LiveVideo,
PlayerVideoSettings,
UserVideoQuota,
VideoCaption,
VideoChapter,
@ -22,6 +23,8 @@ import {
import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
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 = {
video: VideoDetails
@ -33,6 +36,8 @@ export type VideoManageResolverData = {
videoPasswords: VideoPassword[]
userQuota: UserVideoQuota
privacies: VideoConstant<VideoPrivacyType>[]
videoEdit: VideoEdit
playerSettings: PlayerVideoSettings
}
@Injectable()
@ -45,6 +50,7 @@ export class VideoManageResolver {
private videoPasswordService = inject(VideoPasswordService)
private userService = inject(UserService)
private serverService = inject(ServerService)
private playerSettingsService = inject(PlayerSettingsService)
resolve (route: ActivatedRouteSnapshot) {
const uuid: string = route.params['uuid']
@ -52,18 +58,32 @@ export class VideoManageResolver {
return this.videoService.getVideo({ videoId: uuid })
.pipe(
switchMap(video => forkJoin(this.buildObservables(video))),
map(([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies ]) =>
({
video,
userChannels,
captions,
chapters,
videoSource,
live,
videoPasswords,
userQuota,
privacies
}) as VideoManageResolverData
switchMap(
async ([ video, videoSource, userChannels, captions, chapters, live, videoPasswords, userQuota, privacies, playerSettings ]) => {
const videoEdit = await VideoEdit.createFromAPI(this.serverService.getHTMLConfig(), {
video,
captions,
chapters,
live,
videoSource,
playerSettings,
videoPasswords: videoPasswords.map(p => p.password)
})
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
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
: of([]),
: of([] as VideoPassword[]),
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 })))
.subscribe({
next: async video => {
await videoEdit.loadFromAPI({ video })
await videoEdit.loadFromAPI({ video, loadPrivacy: false })
this.loadingBar.useRef().complete()

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import { inject } from '@angular/core'
import { RedirectCommand, Router, Routes } from '@angular/router'
import { CanDeactivateGuard, LoginGuard } from '@app/core'
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 { VideoUploadService } from '../shared-manage/common/video-upload.service'
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 { VideoPublishComponent } from './video-publish.component'
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')
@ -43,6 +44,7 @@ export default [
providers: [
VideoPublishResolver,
VideoManageController,
PlayerSettingsService,
VideoStateMessageService,
LiveVideoService,
I18nPrimengCalendarService,

View file

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

View file

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

View file

@ -31,8 +31,15 @@
</div>
<p-datepicker
inputId="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" [firstDayOfWeek]="0"
[showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange"
inputId="originallyPublishedAt"
formControlName="originallyPublishedAt"
[dateFormat]="calendarDateFormat"
[firstDayOfWeek]="0"
[showTime]="true"
[hideOnDateTimeSelect]="true"
[monthNavigator]="true"
[yearNavigator]="true"
[yearRange]="myYearRange"
baseZIndex="20000"
>
</p-datepicker>
@ -42,10 +49,15 @@
</div>
</div>
<my-peertube-checkbox
inputName="downloadEnabled" formControlName="downloadEnabled"
i18n-labelText labelText="Enable download"
></my-peertube-checkbox>
<my-peertube-checkbox inputName="downloadEnabled" formControlName="downloadEnabled" i18n-labelText labelText="Enable download"></my-peertube-checkbox>
<div class="form-group" formGroupName="playerSettings">
<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>

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 { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
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 { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
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 { DatePickerModule } from 'primeng/datepicker'
import { Subscription } from 'rxjs'
@ -19,6 +20,10 @@ const debugLogger = debug('peertube:video-manage')
type Form = {
downloadEnabled: FormControl<boolean>
originallyPublishedAt: FormControl<Date>
playerSettings: FormGroup<{
theme: FormControl<PlayerVideoSettings['theme']>
}>
}
@Component({
@ -28,12 +33,13 @@ type Form = {
],
templateUrl: './video-customization.component.html',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
NgIf,
DatePickerModule,
PeertubeCheckboxComponent,
GlobalIconComponent
GlobalIconComponent,
SelectPlayerThemeComponent
]
})
export class VideoCustomizationComponent implements OnInit, OnDestroy {
@ -47,6 +53,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit
videoChannel: Pick<VideoChannel, 'name' | 'displayName'>
calendarDateFormat: string
myYearRange: string
@ -63,17 +70,24 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const { videoEdit } = this.manageController.getStore()
const { videoEdit, userChannels } = this.manageController.getStore()
this.videoEdit = videoEdit
const channelItem = userChannels.find(c => c.id === videoEdit.toCommonFormPatch().channelId)
this.videoChannel = { name: channelItem.name, displayName: channelItem.label }
this.buildForm()
}
private buildForm () {
const defaultValues = this.videoEdit.toCommonFormPatch()
const obj: BuildFormArgument = {
const defaultValues = { ...this.videoEdit.toCommonFormPatch(), playerSettings: this.videoEdit.toPlayerSettingsFormPatch() }
const obj: BuildFormArgumentTyped<Form> = {
downloadEnabled: null,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
playerSettings: {
theme: null
}
}
const {
@ -93,12 +107,18 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
debugLogger('Updating form values', formValues)
this.videoEdit.loadFromCommonForm(formValues)
this.videoEdit.loadFromPlayerSettingsForm({
theme: formValues.playerSettings.theme
})
})
this.formReactiveService.markAllAsDirty(this.form.controls)
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)
this.serverService.getVideoLicences()
.subscribe(res => this.videoLicences = res)
.subscribe(res => this.videoLicences = this.videoService.explainedLicenceLabels(res))
this.buildLanguages()
this.buildPrivacies()
@ -410,7 +410,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
waitTranscodingControl.disable()
if (!isInitialPatch) waitTranscodingControl.setValue(false)
} else {
} else if (waitTranscodingControl.disabled) {
scheduleControl.clearValidators()
waitTranscodingControl.enable()

View file

@ -1,7 +1,6 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root {
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 { VideoService } from '@app/shared/shared-main/video/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 {
HTMLServerConfig,
@ -47,6 +48,7 @@ export class VideoManageController implements OnDestroy {
private formReactiveService = inject(FormReactiveService)
private videoStudio = inject(VideoStudioService)
private peertubeRouter = inject(PeerTubeRouterService)
private playerSettingsService = inject(PlayerSettingsService)
private videoEdit: VideoEdit
private userChannels: SelectChannelItem[]
@ -245,6 +247,16 @@ export class VideoManageController implements OnDestroy {
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(() => {
if (!isLive || !this.videoEdit.hasLiveChanges()) return of(true)
@ -283,16 +295,19 @@ export class VideoManageController implements OnDestroy {
!isLive
? 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({
video,
videoPasswords: videoPasswords.map(p => p.password),
live,
chapters: chaptersRes?.chapters,
captions: captionsRes?.data
captions: captionsRes?.data,
playerSettings
})
}),
first(), // To complete

View file

@ -8,6 +8,7 @@ import {
ServerStats,
VideoCommentPolicy,
VideoConstant,
VideoLicenceType,
VideoPlaylistPrivacyType,
VideoPrivacyType
} from '@peertube/peertube-models'
@ -30,7 +31,7 @@ export class ServerService {
configReloaded = new Subject<ServerConfig>()
private localeObservable: Observable<any>
private videoLicensesObservable: Observable<VideoConstant<number>[]>
private videoLicensesObservable: Observable<VideoConstant<VideoLicenceType>[]>
private videoCategoriesObservable: Observable<VideoConstant<number>[]>
private videoPrivaciesObservable: Observable<VideoConstant<VideoPrivacyType>[]>
private videoPlaylistPrivaciesObservable: Observable<VideoConstant<VideoPlaylistPrivacyType>[]>
@ -129,7 +130,7 @@ export class ServerService {
getVideoLicences () {
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())

View file

@ -37,7 +37,7 @@
<my-notification-dropdown class="margin-button"></my-notification-dropdown>
<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"
></my-button>

View file

@ -11,7 +11,7 @@
<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">
<div class="form-group">

View file

@ -47,59 +47,59 @@ export class CommunityBasedConfigComponent implements OnInit {
registrationOptions: SelectOptionsItem<RegistrationType>[] = [
{
id: 'open',
label: 'Open',
description: 'Anyone can register and use the platform'
label: $localize`Open`,
description: $localize`Anyone can register and use the platform`
},
{
id: 'approval',
label: 'Requires approval',
description: 'Anyone can register, but a moderator must approve their account before they can use the platform'
label: $localize`Requires approval`,
description: $localize`Anyone can register, but a moderator must approve their account before they can use the platform`
},
{
id: 'closed',
label: 'Closed',
description: 'Only an administrator can create users on the platform'
label: $localize`Closed`,
description: $localize`Only an administrator can create users on the platform`
}
]
importOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
label: $localize`Enabled`,
description:
'Your community can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels'
},
{
id: 'disabled',
label: 'Disabled',
description: 'Your community cannot import or synchronize content from remote platforms'
label: $localize`Disabled`,
description: $localize`Your community cannot import or synchronize content from remote platforms`
}
]
liveOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes',
description: 'Your community can live stream on the platform (this requires extra moderation work)'
label: $localize`Yes`,
description: $localize`Your community can live stream on the platform (this requires extra moderation work)`
},
{
id: 'disabled',
label: 'No',
description: 'Your community is not permitted to run live streams on the platform'
label: $localize`No`,
description: $localize`Your community is not permitted to run live streams on the platform`
}
]
globalSearchOptions: SelectOptionsItem<string>[] = [
{
id: 'enabled',
label: 'Enable global search',
description: 'Use https://sepiasearch.org as default search engine to search for content across all known peertube platforms'
label: $localize`Enable global search`,
description: $localize`Use https://sepiasearch.org as default search engine to search for content across all known PeerTube platforms`
},
{
id: 'disabled',
label: 'Disable global search',
description: 'Use your platform search engine which only displays local content'
label: $localize`Disable global search`,
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>[] = [
{
id: 'enabled',
label: 'Enabled',
description: 'Enable P2P streaming by default for anonymous and new users'
label: $localize`Enabled`,
description: $localize`Enable P2P streaming by default for anonymous and new users`
},
{
id: 'disabled',
label: 'Disabled',
description: 'Disable P2P streaming'
label: $localize`Disabled`,
description: $localize`Disable P2P streaming`
}
]
transcriptionOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
description: 'Enable automatic transcription of videos to automatically generate subtitles'
label: $localize`Enabled`,
description: $localize`Enable automatic transcription of videos to automatically generate subtitles`
},
{
id: 'disabled',
label: 'Disabled',
description: 'Disable automatic transcription of videos'
label: $localize`Disabled`,
description: $localize`Disable automatic transcription of videos`
}
]
keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes',
description: 'Keep the original video file on the server'
label: $localize`Yes`,
description: $localize`Keep the original video file on the server`
},
{
id: 'disabled',
label: 'No',
description: 'Delete the original video file after processing'
label: $localize`No`,
description: $localize`Delete the original video file after processing`
}
]
authenticationOptions: SelectOptionsItem<AuthType>[] = [
{
id: 'local',
label: 'Disabled',
description: 'Your platform will manage user registration and login internally'
label: $localize`Disabled`,
description: $localize`Your platform will manage user registration and login internally`
},
{
id: 'ldap',
label: 'LDAP',
description: 'Use LDAP for user authentication'
label: $localize`LDAP`,
description: $localize`Use LDAP for user authentication`
},
{
id: 'oidc',
label: 'OIDC',
description: 'Use OpenID Connect for user authentication'
label: $localize`OIDC`,
description: $localize`Use OpenID Connect for user authentication`
},
{
id: 'saml',
label: 'SAML',
description: 'Use SAML 2.0 for user authentication'
label: $localize`SAML`,
description: $localize`Use SAML 2.0 for user authentication`
}
]

View file

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

View file

@ -160,7 +160,7 @@ export class UsageType {
if (!exists(this.registration)) return
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({
signup: {
@ -169,7 +169,9 @@ export class UsageType {
}
})
} 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({
signup: {
@ -178,7 +180,7 @@ export class UsageType {
}
})
} 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({
signup: {
@ -188,7 +190,9 @@ export class UsageType {
}
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({
autoBlacklist: {
@ -213,13 +217,18 @@ export class UsageType {
if (this.videoQuota === 0) {
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) {
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 {
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') {
this.addExplanation(
// 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 {
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(
// 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 {
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) {
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) {
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 () {
@ -320,9 +338,13 @@ export class UsageType {
})
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 {
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') {
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 {
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') {
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') {
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') {
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') {
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 () {
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') {
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')
} 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')
} 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')
}

View file

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor">
<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 (hasAvatar()) {

View file

@ -41,7 +41,7 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
maxAvatarSize = 0
avatarExtensions = ''
preview: string
previewUrl: string
actor: ActorAvatarInput
@ -55,6 +55,8 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
}
ngOnChanges () {
this.previewUrl = undefined
this.actor = {
avatars: this.avatars(),
name: this.username()
@ -73,16 +75,23 @@ export class ActorAvatarEditComponent implements OnInit, OnChanges {
this.avatarChange.emit(formData)
if (this.previewImage()) {
imageToDataURL(avatarfile).then(result => this.preview = result)
imageToDataURL(avatarfile).then(result => this.previewUrl = result)
}
}
deleteAvatar () {
this.preview = undefined
if (this.previewImage()) {
this.previewUrl = null
this.actor.avatars = []
}
this.avatarDelete.emit()
}
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-img-edit-container">
<div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || bannerUrl()" alt="Banner" />
<img *ngIf="hasBanner()" [src]="getBannerUrl()" alt="Banner" />
</div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</div>
<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>
@if (!hasBanner()) {
<div class="actor-img-edit-button button-file primary-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container>
</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>

View file

@ -1,8 +1,8 @@
import { NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, inject, input, output, viewChild } from '@angular/core'
import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component, ElementRef, OnInit, booleanAttribute, inject, input, output, viewChild } from '@angular/core'
import { SafeResourceUrl } from '@angular/platform-browser'
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 { imageToDataURL } from '@root-helpers/images'
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-banner-edit.component.scss'
],
imports: [ NgIf, NgbTooltip, NgTemplateOutlet, NgbDropdown, NgbDropdownToggle, GlobalIconComponent, NgbDropdownMenu ]
imports: [ CommonModule, NgbTooltipModule, NgTemplateOutlet, NgbDropdownModule, GlobalIconComponent ]
})
export class ActorBannerEditComponent implements OnInit {
private serverService = inject(ServerService)
@ -23,8 +23,8 @@ export class ActorBannerEditComponent implements OnInit {
readonly bannerfileInput = viewChild<ElementRef<HTMLInputElement>>('bannerfileInput')
readonly bannerPopover = viewChild<NgbPopover>('bannerPopover')
readonly bannerUrl = input<string>(undefined)
readonly previewImage = input(false)
readonly bannerUrl = input<string>()
readonly previewImage = input(false, { transform: booleanAttribute })
readonly bannerChange = output<FormData>()
readonly bannerDelete = output()
@ -63,11 +63,23 @@ export class ActorBannerEditComponent implements OnInit {
}
deleteBanner () {
this.preview = undefined
if (this.previewImage()) {
this.preview = null
}
this.bannerDelete.emit()
}
hasBanner () {
// User deleted the avatar
if (this.preview === null) return false
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'
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingResolutionOptions: ResolutionOption[] = []
constructor () {
this.transcodingThreadOptions = [
@ -48,13 +47,18 @@ export class AdminConfigService {
{ id: 16, label: '16' },
{ id: 32, label: '32' }
]
}
this.transcodingResolutionOptions = [
// ---------------------------------------------------------------------------
getTranscodingOptions (type: 'live' | 'vod'): ResolutionOption[] {
return [
{
id: '0p',
label: $localize`Audio-only`,
description:
$localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
description: type === 'vod'
? $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
: undefined
},
{
id: '144p',
@ -141,10 +145,10 @@ export class AdminConfigService {
// ---------------------------------------------------------------------------
buildFormResolutions () {
buildFormResolutions (type: 'live' | 'vod') {
const formResolutions = {} as Record<keyof FormResolutions, BuildFormValidator>
for (const resolution of this.transcodingResolutionOptions) {
for (const resolution of this.getTranscodingOptions(type)) {
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>

View file

@ -27,7 +27,7 @@
<ng-template [ngTemplateOutlet]="customItemTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
} @else {
<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" />
<span class="ellipsis" [ngClass]="item.classes">{{ item.label }}</span>

View file

@ -1,5 +1,5 @@
@use '_variables' as *;
@use '_mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
img {
border-radius: 50%;
@ -10,8 +10,15 @@ img {
.muted {
font-size: 90%;
line-height: 1.2;
display: block;
white-space: normal;
}
.item-label {
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 { PeerTubeRouterService } from '@app/core'
import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
@ -8,7 +7,6 @@ import { distinctUntilChanged, filter, map, share, startWith, throttleTime } fro
standalone: true
})
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
private peertubeRouter = inject(PeerTubeRouterService)
private el = inject(ElementRef)
readonly percentLimit = input(70)
@ -18,7 +16,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
readonly nearOfBottom = output()
private decimalLimit = 0
private lastCurrentBottom = -1
private lastCurrentBottom: number
private scrollDownSub: Subscription
private container: HTMLElement
@ -98,6 +96,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
}
private isScrollingDown (current: number) {
if (this.lastCurrentBottom === undefined) {
this.lastCurrentBottom = current
return false
}
const result = this.lastCurrentBottom < current
this.lastCurrentBottom = current

View file

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

View file

@ -31,6 +31,8 @@ import {
VideoDetails as VideoDetailsServerModel,
VideoFile,
VideoFileMetadata,
VideoLicence,
VideoLicenceType,
VideoPrivacy,
VideoPrivacyType,
VideosCommonQuery,
@ -526,6 +528,8 @@ export class VideoService {
)
}
// ---------------------------------------------------------------------------
explainedPrivacyLabels (serverPrivacies: VideoConstant<VideoPrivacyType>[], defaultPrivacyId: VideoPrivacyType = VideoPrivacy.PUBLIC) {
const descriptions = {
[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'>) {
const flags: string[] = []

View file

@ -27,7 +27,7 @@
@if (isInSelectionMode()) {
<my-action-dropdown i18n-label label="Batch actions" theme="primary" [actions]="bulkActions()" [entry]="selectedRows"></my-action-dropdown>
} @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>
</strong>

View file

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

View file

@ -3,7 +3,7 @@
[internalLink]="playlistRouterLink" [href]="playlistHref" [target]="playlistTarget" inheritParentStyle="true" inheritParentDimension="true"
[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">
<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 toManage = input(false)
readonly toManage = input.required({ 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, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AfterViewInit, Component, inject } from '@angular/core'
import { Router } from '@angular/router'
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 { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { HttpStatusCode, VideoChannelCreate } from '@peertube/peertube-models'
import { HttpStatusCode, PlayerChannelSettings, VideoChannelCreate } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-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'
import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
@Component({
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
template: `
<my-video-channel-edit
mode="create" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
`,
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
VideoChannelEditComponent
],
providers: [
PlayerSettingsService
]
})
export class VideoChannelCreateComponent extends VideoChannelEdit implements OnInit, AfterViewInit {
protected formReactiveService = inject(FormReactiveService)
export class VideoChannelCreateComponent implements AfterViewInit {
private authService = inject(AuthService)
private notifier = inject(Notifier)
private router = inject(Router)
private videoChannelService = inject(VideoChannelService)
private hooks = inject(HooksService)
private playerSettingsService = inject(PlayerSettingsService)
error: string
videoChannel = new VideoChannel({})
private avatar: FormData
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
})
channel = new VideoChannel({})
rawPlayerSettings: PlayerChannelSettings = {
theme: 'instance-default'
}
ngAfterViewInit () {
this.hooks.runAction('action:video-channel-create.init', 'video-channel')
}
formValidated () {
onFormValidated (output: FormValidatedOutput) {
this.error = undefined
const body = this.form.value
const videoChannelCreate: VideoChannelCreate = {
name: body.name,
displayName: body['display-name'],
description: body.description || null,
support: body.support || null
const channelCreate: VideoChannelCreate = {
name: output.channel.name,
displayName: output.channel.displayName,
description: output.channel.description,
support: output.channel.support
}
this.videoChannelService.createVideoChannel(videoChannelCreate)
this.videoChannelService.createVideoChannel(channelCreate)
.pipe(
switchMap(() => this.uploadAvatar()),
switchMap(() => this.uploadBanner())
switchMap(() => {
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({
next: () => {
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' ])
},
error: err => {
let message = err.message
if (err.status === HttpStatusCode.CONFLICT_409) {
this.error = $localize`This name already exists on this platform.`
return
message = $localize`Channel name "${channelCreate.name}" already exists on this platform.`
}
this.error = err.message
this.notifier.error(message)
}
})
}
onAvatarChange (formData: FormData) {
this.avatar = formData
private uploadAvatar (username: string, avatar?: FormData) {
if (!avatar) return of(undefined)
return this.videoChannelService.changeVideoChannelImage(username, avatar, 'avatar')
}
onAvatarDelete () {
this.avatar = null
}
private uploadBanner (username: string, banner?: FormData) {
if (!banner) return of(undefined)
onBannerChange (formData: FormData) {
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')
return this.videoChannelService.changeVideoChannelImage(username, 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">
<form (ngSubmit)="formValidated()" [formGroup]="form">
<form (ngSubmit)="onFormValidated()" [formGroup]="form">
<div class="pt-two-cols"> <!-- channel grid -->
<div class="title-col">
@if (isCreation()) {
@if (mode() === 'create') {
<h2 i18n>NEW CHANNEL</h2>
} @else {
<h2 i18n>UPDATE CHANNEL</h2>
@ -14,40 +14,40 @@
<div class="content-col">
<my-actor-banner-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4"
[bannerUrl]="videoChannel?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
*ngIf="channel()" previewImage="true" class="d-block mb-4"
[bannerUrl]="channel()?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
<my-actor-avatar-edit
*ngIf="videoChannel" class="d-block mb-4" actorType="channel"
[displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars"
[username]="!isCreation() && videoChannel.name" [subscribers]="!isCreation() && videoChannel.followersCount"
*ngIf="channel()" class="d-block mb-4" actorType="channel"
[displayName]="channel().displayName" previewImage="true" [avatars]="channel().avatars"
[username]="mode() === 'update' && channel().name" [subscribers]="mode() === 'update' && channel().followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()">
<div class="form-group" *ngIf="mode() === 'create'">
<label i18n for="name">Name</label>
<div class="input-group">
<input
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>
<div *ngIf="formErrors['name']" class="form-error" role="alert">
{{ formErrors['name'] }}
<div *ngIf="formErrors.name" class="form-error" role="alert">
{{ formErrors.name }}
</div>
</div>
<div class="form-group">
<label i18n for="display-name">Display name</label>
<label i18n for="displayName">Display name</label>
<input
type="text" id="display-name" class="form-control"
formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
type="text" id="displayName" class="form-control"
formControlName="displayName" [ngClass]="{ 'input-error': formErrors.displayName }"
>
<div *ngIf="formErrors['display-name']" class="form-error" role="alert">
{{ formErrors['display-name'] }}
<div *ngIf="formErrors.displayName" class="form-error" role="alert">
{{ formErrors.displayName }}
</div>
</div>
@ -58,7 +58,7 @@
<my-markdown-textarea
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>
<div *ngIf="formErrors.description" class="form-error" role="alert">
@ -75,7 +75,7 @@
<my-markdown-textarea
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>
</div>
@ -86,6 +86,13 @@
></my-peertube-checkbox>
</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">
</div>
</div>

View file

@ -1,16 +1,16 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
my-actor-banner-edit {
max-width: 500px;
}
input[type=text] {
input[type="text"] {
@include peertube-input-text(340px);
}
input[type=submit] {
input[type="submit"] {
@include margin-left(auto);
}
@ -18,6 +18,8 @@ input[type=submit] {
max-width: 500px;
}
.peertube-select-container {
@include peertube-select-container(340px);
my-select-player-theme {
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 { HttpErrorResponse } from '@angular/common/http'
import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AfterViewInit, Component, inject, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AuthService, HooksService, Notifier, RedirectService } from '@app/core'
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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { shallowCopy } from '@peertube/peertube-core-utils'
import { VideoChannelUpdate } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { ActorAvatarEditComponent } from '../shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../shared-actor-image-edit/actor-banner-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'
import { PlayerChannelSettings, VideoChannelUpdate } from '@peertube/peertube-models'
import { catchError, forkJoin, Subscription, switchMap, tap, throwError } from 'rxjs'
import { VideoChannel } from '../shared-main/channel/video-channel.model'
import { PlayerSettingsService } from '../shared-video/player-settings.service'
import { FormValidatedOutput, VideoChannelEditComponent } from './video-channel-edit.component'
@Component({
selector: 'my-video-channel-update',
templateUrl: './video-channel-edit.component.html',
styleUrls: [ './video-channel-edit.component.scss' ],
template: `
@if (channel && rawPlayerSettings) {
<my-video-channel-edit
mode="update" [channel]="channel" [rawPlayerSettings]="rawPlayerSettings" [error]="error"
(formValidated)="onFormValidated($event)"
>
</my-video-channel-edit>
}
`,
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
ActorBannerEditComponent,
ActorAvatarEditComponent,
NgClass,
HelpComponent,
MarkdownTextareaComponent,
PeertubeCheckboxComponent,
AlertComponent,
MarkdownHintComponent
VideoChannelEditComponent
],
providers: [
PlayerSettingsService
]
})
export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnInit, AfterViewInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService)
export class VideoChannelUpdateComponent implements OnInit, AfterViewInit, OnDestroy {
private authService = inject(AuthService)
private notifier = inject(Notifier)
private route = inject(ActivatedRoute)
private videoChannelService = inject(VideoChannelService)
private playerSettingsService = inject(PlayerSettingsService)
private redirectService = inject(RedirectService)
private hooks = inject(HooksService)
channel: VideoChannel
rawPlayerSettings: PlayerChannelSettings
error: string
private paramsSub: Subscription
private oldSupportField: string
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 => {
const videoChannelName = routeParams['videoChannelName']
this.videoChannelService.getVideoChannel(videoChannelName)
.subscribe({
next: videoChannelToUpdate => {
this.videoChannel = videoChannelToUpdate
forkJoin([
this.videoChannelService.getVideoChannel(videoChannelName),
this.playerSettingsService.getChannelSettings({ channelHandle: videoChannelName, raw: true })
]).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
this.form.patchValue({
'display-name': videoChannelToUpdate.displayName,
'description': videoChannelToUpdate.description,
'support': videoChannelToUpdate.support
})
},
error: err => {
this.error = err.message
}
})
error: err => this.notifier.error(err.message)
})
})
}
@ -98,112 +71,84 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
if (this.paramsSub) this.paramsSub.unsubscribe()
}
formValidated () {
onFormValidated (output: FormValidatedOutput) {
this.error = undefined
const body = this.form.value
const videoChannelUpdate: VideoChannelUpdate = {
displayName: body['display-name'],
description: body.description || null,
support: body.support || null,
bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
displayName: output.channel.displayName,
description: output.channel.description,
support: output.channel.support,
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({
next: () => {
// So my-actor-avatar component detects changes
this.channel = shallowCopy(this.channel)
this.authService.refreshUserInformation()
this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`)
this.redirectService.redirectToPreviousRoute('/c/' + this.videoChannel.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)
this.redirectService.redirectToPreviousRoute('/c/' + this.channel.name)
},
error: err => this.notifier.error(err.message)
})
}
onBannerChange (formData: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannel.name, formData, 'banner')
.subscribe({
next: data => {
this.notifier.success($localize`Banner changed.`)
private updateOrDeleteAvatar (avatar: FormData) {
if (!avatar) {
return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'avatar')
.pipe(tap(() => this.channel.resetAvatar()))
}
this.videoChannel.updateBanner(data.banners)
},
error: (err: HttpErrorResponse) =>
genericUploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier
return this.videoChannelService.changeVideoChannelImage(this.channel.name, avatar, 'avatar')
.pipe(
tap(data => this.channel.updateAvatar(data.avatars)),
catchError(err =>
throwError(() => {
return new Error(genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
}))
})
})
)
)
}
onBannerDelete () {
this.videoChannelService.deleteVideoChannelImage(this.videoChannel.name, 'banner')
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
private updateOrDeleteBanner (banner: FormData) {
if (!banner) {
return this.videoChannelService.deleteVideoChannelImage(this.channel.name, 'banner')
.pipe(tap(() => this.channel.resetBanner()))
}
this.videoChannel.resetBanner()
},
error: err => this.notifier.error(err.message)
})
}
isCreation () {
return false
}
getFormButtonTitle () {
return $localize`Update ${this.videoChannel?.name}`
}
isBulkUpdateVideosDisplayed () {
if (this.oldSupportField === undefined) return false
return this.oldSupportField !== this.form.value['support']
return this.videoChannelService.changeVideoChannelImage(this.channel.name, banner, 'banner')
.pipe(
tap(data => this.channel.updateBanner(data.banners)),
catchError(err =>
throwError(() => {
return new Error(genericUploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier
}))
})
)
)
}
}

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