Compare commits

...
Author SHA1 Message Date
ff1a016c91
Persist PostIt podcast device and title preferences
All checks were successful
Forgejo Release / release (push) Successful in 13m7s
Remember detected capture devices, camera and microphone selections, and the default podcast title in user settings. Restore preferences on page creation and surface save failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-02 14:13:48 +00:00
4a5a3c8844
Prepare podcast emissions using LiveFlow
Persist scheduled emissions, expose owner-only planning controls in PostIt and publish optional presentation-only announcements on Yavsc.Org. Preserve manual broadcasting and authenticated playback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-02 14:08:22 +00:00
e3b4414ab1
Dons PayPal 2026-11-02 10:40:29 +00:00
20a44bfde1
warn on podcast deletion 2026-11-01 20:17:10 +00:00
89d9137884
Open Podcast in external browser (WIP) 2026-11-01 18:53:38 +00:00
9578a9c416
dotnet workload restore 2026-11-01 18:51:46 +00:00
a676ef3575
feat(calls): add Android LAN peer calls using Podcast capture
Share the LibVLC SRTP engine with Linux and feed Android MediaRecorder output through its native pipe without transcoding or HLS files. Reuse front-camera capture, preview, remote rendering and foreground service with consent-based controls and background cleanup.

Use separate RTP/RTCP ports to avoid the diagnosed Android VLC SDP crash. Add physical-device media checks and lifecycle regressions; document LAN/VPN scope and remaining separate-device validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 04:15:19 +00:00
cc28ee8b29
feat(calls): implement Linux LAN peer calls over SRTP
Reuse existing ChatHub consent and Podcast LibVLC capture for direct bidirectional H.264/AAC media, with embedded call controls and cleanup. Require HTTPS key exchange and reject IP-masking policies for this opt-in LAN/VPN transport.

Add native SRTP, signaling and lifecycle tests; document setup and remaining physical-device validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 03:05:23 +00:00
5e48831d6d
feat(calls): add consent-based signaling to existing ChatHub
Wire shared PostIt call signaling, account-derived relay policy and temporary TURN credentials. Keep calls disabled pending native Android/Linux media integration. Document experimental Android crash and laboratory removal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 01:00:19 +00:00
56ca115f50
+dotnet-ef 2026-10-25 18:33:41 +00:00
e47bc10026
git version 2026-10-25 16:53:07 +00:00
d8200cb98d
feat(postit): persist call IP privacy preference
Store HideIpAddressInCalls on user accounts, expose it in the profile API and PostIt editor, and preserve preferences for older clients. Add the EF migration, regression tests and architecture documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-25 16:49:51 +00:00
df32ec11bf
archidoc calls 2026-10-25 14:02:03 +00:00
c2068f1476
feat(postit): add live capture previews on Android and Desktop
Reuse oriented Android EGL frames and duplicate Desktop video output before encoding without microphone monitoring. Cover preview lifecycle, rendering and recording with targeted tests and document the feature.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-25 01:03:36 +00:00
1dcbfe159a
feat(podcasts): prepare seekable MP4 replays and overlay controls
Remux completed broadcasts with FFmpeg, retain source segments and account for both formats. Serve authenticated byte ranges and add native Android/Desktop replay controls with auto-hide.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-25 00:48:53 +01:00
152 changed files with 24517 additions and 277 deletions

View file

@ -1,6 +1,27 @@
# Changelog
## [1.0.8-rc34] - unstable
### Added
* [PostIt] Remember detected podcast cameras/microphones, selected devices (including no microphone) and the default title in user settings; restore them when reopening the page and report persistence failures.
* [Podcasts] Reuse persisted LiveFlow entities to prepare, edit and delete emissions from PostIt with a title, scheduled start and estimated duration. Keep start/stop manual and plans private; optionally publish presentation-only announcements on Yavsc.Org without opening anonymous playback. Preserve broadcast identifiers, legacy recordings and authenticated HLS/MP4 access; add a scheduling migration and lifecycle/privacy regressions.
* [PostIt/API/Yavsc.Org] Expose the private PayPal recipient setting in PostIt's performer editor and authenticated profile API; validate addresses, allow disabling with an empty value and preserve the setting for older clients. Make Web donation settings discoverable from the performer profile and podcast catalogue.
* [API] Initialize the required address field when creating a performer profile so a first configuration can be saved.
* [Yavsc.Org] Add an authenticated Razor podcast catalogue with same-origin API-backed HLS/MP4 playback and replay byte-range support. Reuse Site.ApiUrl and the authority's signing credentials to issue short-lived user tokens, without a separate OAuth client or PodcastWeb configuration. Distinguish an empty catalogue from API/configuration failures.
* [Yavsc.Org] Integrate direct PayPal donations to performers: self-service recipient settings, Yavsc donation pages/history, server-verified checkout and capture, antiforgery/concurrency protection, and validated IPN reconciliation. Reuse the existing merchant settings and persist donation receipts in a dedicated migration; keep uncertain and pending payments distinct from completed donations.
* [PostIt] Open the Yavsc.Org podcast catalogue in the browser instead of the authenticated API playlist; preserve embedded playback and external blog-video links.
* [PostIt.Android/Desktop] Extend LAN/VPN SRTP calls to Android 8+ using the existing front-camera/microphone Podcast capture, rotated native preview and embedded LibVLC remote video, without libwebrtc or new dependencies. Pass the live MediaRecorder pipe directly to the shared SRTP engine without HLS files or transcoding; add a call-specific foreground notification, stop on backgrounding, and allow incoming callers to configure devices before acceptance. Use separate RTP/RTCP UDP ports to avoid a diagnosed Android VLC `rtcp-mux` SDP-generation crash. Add debug-only physical camera/SRTP checks and shared control/pipe-input regressions; Internet/TURN/group calls remain outside this transport.
* [PostIt.Desktop/API] Add opt-in Linux LAN/VPN peer calls through the existing ChatHub, reusing Podcast LibVLC camera/microphone capture and H.264/AAC encoding with direct SRTP instead of HLS. Add consent, embedded local/remote video, hangup/interruption cleanup, private IPv4 validation and per-direction ephemeral keys over HTTPS-only signaling. Reject IP-masking accounts rather than silently bypassing TURN; keep this transport disabled by default and separate from the future Internet/group-call design. Test real bidirectional encrypted audio/video, wrong-key rejection, preview output, signaling and UI lifecycle; physical camera/two-machine rendering remains to validate.
* [PostIt/API] Begin Android/Linux call integration on the existing authenticated ChatHub: typed invitation, single-device consent, bounded SDP/ICE exchange and hangup; derive relay policy from account preferences and issue temporary coturn credentials. Attach the shared PostIt signaling client to the chat connection, end calls on disconnect/room departure/privacy activation, and add targeted regression tests. Calls remain disabled by default; native media adapters and Android/Linux end-to-end validation are not implemented yet.
* [PostIt] Clarify reuse of the existing ChatHub for call signaling and document the inconclusive Android native WebRTC experiment; discard the isolated laboratory without changing production chat or media behavior.
* [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet.
* [PostIt] Show a local live capture preview on Android and Desktop without opening a second camera or reading back uploaded segments. Share Android's oriented EGL frames with an aspect-preserving preview surface; duplicate Desktop LibVLC output without local microphone playback.
* [Podcasts] Prepare authenticated, seekable MP4 replays for new and existing Completed broadcasts using server-side FFmpeg stream copy and faststart. Retain MPEG-TS, account for both copies in the owner quota, recover preparation after restarts, and expose owner-only retries plus GET/HEAD byte-range playback.
* [PostIt] Add Android/Desktop native video overlays for MP4 replay position, duration, seeking and play/pause, with three-second auto-hide. Use bounded authenticated range input with token refresh; preserve MPEG-TS live/interrupted playback.
## [1.0.8-rc33] - unstable
### Added

View file

@ -93,6 +93,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
ENTRYPOINT ["dotnet", "Yavsc.Org.dll"]
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api-runtime
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && apt-get clean
WORKDIR /app
COPY --from=publish-api /publish/ ./
RUN --mount=type=secret,id=yavsc_appsettings,dst=/run/secrets/yavsc_appsettings \

View file

@ -81,8 +81,15 @@ these Windows runtime behaviours.
PostIt Desktop's home page provides **Podcasts : directs et replays**.
Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the
Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the
default PulseAudio microphone. FFmpeg is **not** a runtime dependency.
default PulseAudio microphone. FFmpeg is **not** a client dependency.
Select a camera and optionally a microphone, enter a title, then start.
An **Apercu local de la capture** appears at the top of the Podcasts page on
Android and Desktop. It uses the current capture, not a second camera session
or the server replay. Android displays the same oriented frames as the encoder,
with black bars to preserve proportions; Desktop duplicates LibVLC's video
output. Local microphone playback is disabled to avoid acoustic feedback.
Stopping releases the preview; hiding/recreating the Android preview surface
does not restart the encoder or change its timestamps.
Stopping publishes the retained replay; navigating away or logging out
stops capture too. A second signed-in PostIt instance can watch the live
broadcast while it is being captured.
@ -93,7 +100,11 @@ replays; only the broadcaster can upload, complete or delete their podcast.
Media is not anonymous and no access token is included in a playback URL.
Configure Yavsc.Api's `Podcast` section (or environment variables such as
`Podcast__RootPath`). No database migration or server-side encoder is needed:
`Podcast__RootPath`). No database migration is needed. Install **FFmpeg 5+
on the API server** (with `concatf`, MPEG-TS, MP4 and AAC bitstream-filter
support; for Debian/Ubuntu: `apt-get install ffmpeg`). The API runtime stage
in the main Dockerfile includes it. Only stream-copy remuxing is performed,
not video/audio re-encoding:
```json
{
@ -103,7 +114,9 @@ Configure Yavsc.Api's `Podcast` section (or environment variables such as
"MaxOwnerBytes": 1073741824,
"MaxSegments": 7200,
"MaxOwnerPodcasts": 100,
"IdleTimeoutSeconds": 60
"IdleTimeoutSeconds": 60,
"FfmpegPath": "ffmpeg",
"ReplayTimeoutSeconds": 600
}
}
```
@ -120,7 +133,10 @@ owner. At the target two-second segment duration, this is roughly four hours
per episode; the byte quota can be reached earlier. Podcast quotas are
separate from the legacy user-files `DiskQuota`. Deleting an ended podcast
releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body
and forward Authorization on every API request.
and forward Authorization, Range and Content-Range on every API request/response.
The retained MPEG-TS **and** the additional MP4 count toward `MaxOwnerBytes`.
Allow enough free quota and disk space for both copies and the temporary MP4;
deletion removes all copies.
A broadcast without a new committed segment for 60 seconds, or present
when the API restarts, becomes an interrupted replay on the next catalogue,
@ -134,9 +150,26 @@ for manual recovery. There is no automatic resumption after an application
restart; these private local recordings may be removed manually when no
longer needed.
The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated
player joins a live broadcast up to three segments behind the edge and
reads a replay from its beginning; seeking is not implemented. Native
On completion, the API queues a private, seekable **MP4** (`-c copy`,
`+faststart`). The persisted `replay.state` moves through `Pending`,
`Processing`, `Ready` or `Failed`; completion itself does not wait for FFmpeg.
The single API worker also queues older Completed indexes automatically,
recovers interrupted preparation after a restart, and scans every five seconds.
Interrupted broadcasts keep MPEG-TS playback unless the owner explicitly
completes them. Source segments and existing HLS routes remain available.
Preparation failure keeps all source media and reports an error; after checking
server logs, FFmpeg installation, timeout and quota, the owner can use PostIt's
**Relancer la preparation MP4** action, or `POST /api/v1/podcasts/{id}/replay/retry`.
The MP4 is exposed only when ready, at authenticated
`GET /api/v1/podcasts/{id}/replay.mp4` (also HEAD; byte ranges supported).
PostIt's Android and Desktop player waits for MP4 preparation, then fetches
bounded authenticated ranges with token refresh rather than giving LibVLC
a bearer URL. Replay controls overlay the native video: play/pause, position,
duration and draggable seek bar. They hide after three seconds without
interaction while playing, stay visible during seeking/pause/keyboard focus,
and reappear on pointer movement or touch.
The live player still joins up to three segments behind the edge. Native
camera/microphone permissions, drivers and codecs must still be verified
on each target Windows/Linux machine.

View file

@ -38,7 +38,8 @@ Pour une prise en main guidee avec agents IA:
## Construction
```bash
dotnet build
sudo dotnet workload restore
dotnet build
```
et, pour execution en environment de développement

View file

@ -57,6 +57,139 @@ sudo adduser --ingroup yavsc --add-extra-groups www-data \
--disabled-password --system yavsc
```
## Catalogue Web de podcasts
`/Podcast` (vue `src/Yavsc.Org/Views/Podcast/Index.cshtml`) exige une
session Identity Yavsc.Org, y compris pour chaque requête de lecture.
Yavsc.Org interroge Yavsc.Api côté serveur ; aucun jeton n'est transmis
au navigateur et aucun second catalogue de stockage n'est créé.
Les replays MP4 prêts prennent en charge GET, HEAD et les plages
d'octets. Les directs et replays non encore convertis utilisent HLS
natif ou hls.js (servi localement, installé par `npm install` et copié par
`npm run build:podcasts`, également inclus dans la copie d'assets de
`npm run build:js`). Sans support vidéo compatible, la page affiche un
avertissement et invite à utiliser PostIt. Cette page ne crée ni ne
supprime de podcast.
Depuis le lecteur PostIt, « Ouvrir dans le navigateur » ouvre `/Podcast`
sur l'hôte `Authentication.Authority`, pas la playlist API authentifiée.
La session Web reste distincte de celle de PostIt : le navigateur peut
donc demander une connexion.
Il n'y a pas de section de configuration `PodcastWeb`, ni de client OAuth
technique à enregistrer. L'adresse API est celle de `Site:ApiUrl`, en HTTPS
(avec ou sans suffixe `/api/v1`). Yavsc.Org est l'autorité : comme pour
l'envoi d'avatar, `IdentityServerTools.IssueJwtAsync` utilise sa signature
existante pour émettre un jeton de cinq minutes au nom de l'utilisateur
connecté (`sub`, audience `api`, scope `api` attendu par Yavsc.Api).
Ce jeton reste côté serveur. Une adresse API manquante renvoie 503 ;
une panne de l'API renvoie une erreur explicite, pas un catalogue vide.
### Emissions preparees
PostIt memorise dans ses parametres utilisateur les cameras et microphones
detectes, leurs identifiants selectionnes et le titre par defaut. La page
les restaure sans redetection automatique ; le bouton de detection actualise
le materiel disponible en conservant les choix encore presents. Le choix
« sans microphone » est memorise lui aussi. Charger le titre d'une emission
preparee ne remplace pas le titre par defaut ; une saisie explicite le modifie.
Les erreurs de sauvegarde sont affichees dans la page.
PostIt permet de preparer, modifier et supprimer une emission avant son
demarrage : titre, date/heure locale, duree entiere positive en minutes et
annonce publique optionnelle. Les horaires sont previsionnels : la camera,
le demarrage et l'arret restent manuels. Les heures locales inexistantes ou
ambigues au changement d'heure sont refusees.
La table existante `LiveFlow` conserve la preparation et son proprietaire ;
`PodcastId` (GUID unique) relie cette emission au manifeste media sur disque.
`PodcastResponse` reste un DTO, enrichi de `FlowId`, `ScheduledStartAt`,
`PlannedDurationMinutes` et `PublicAnnouncement`. Un etat `Planned`
precede le direct ; la date previsionnelle n'est jamais confondue avec
`CreatedAt` du direct ni la duree previsionnelle avec les secondes enregistrees.
Les anciens manifestes sans `LiveFlow` restent lisibles ; aucune copie ou
migration de leurs medias n'est requise. Le demarrage immediat historique
reste disponible sans preparation.
L'API propose `POST /api/v1/podcasts/planned`,
`PUT /api/v1/podcasts/{id}/planned` et `POST /api/v1/podcasts/{id}/start`.
La preparation n'est visible que du proprietaire, meme avec une annonce ;
les nouvelles routes de mutation exigent le scope `api`. Le demarrage
conserve le GUID et les tentatives concurrentes reutilisent le meme direct.
Une emission demarree ne peut plus etre reprogrammee ou redemarree apres
sa fin. Les medias restent geres par `ILiveProcessor`, avec ses quotas,
expiration, reprise et preparation MP4. Une suppression conserve un
marqueur en base pour ne pas ressusciter une emission deja supprimee.
Le verrou de coordination suit le deploiement mono-processus du stockage
media actuel ; un deploiement multi-instance necessiterait un stockage et
une coordination distribues.
Seul `GET /api/v1/podcasts/announcements` est anonyme : titre, nom d'auteur,
horaire UTC, duree et texte public, sans URL de flux ni metadonnees privees.
`/Podcast/Announcements` affiche ces annonces avec encodage Razor et sans
jeton utilisateur. Retirer le texte public avant le debut retire l'annonce.
Les annonces restent disponibles apres le direct jusqu'a suppression du
podcast. Les pages et toutes les routes de lecture restent authentifiees.
Appliquer la migration `PodcastScheduling` au deploiement, apres celle des
dons ; aucune migration de production n'est appliquee automatiquement ici.
### Dons PayPal aux performers
Les dons sont gérés par Yavsc.Org, pas par un lien de paiement externe
configuré par l'opérateur :
1. Depuis **Mon compte > Dons PayPal**, le performer renseigne l'adresse
de son propre compte PayPal (`/Donation/Settings`). Un champ vide
désactive les nouveaux dons. Seuls les performers actifs ayant
configuré leur compte proposent un bouton de don dans le catalogue.
Le formulaire est aussi accessible depuis le catalogue de podcasts et
le profil professionnel Web. Dans PostIt : **Mon profil > Gérer mon
activité professionnelle > Dons PayPal**, puis **Enregistrer**.
L'API privée `GET/PUT /api/v1/account/performer-profile` transporte
`DonationPayPalEmail` uniquement pour le compte connecté ; une chaîne
vide désactive les dons, une propriété omise ou nulle préserve
l'adresse existante pour les anciens clients.
2. Le donateur connecté choisit un montant en euros (`/Donation/Create`).
Yavsc conserve le don et son bénéficiaire avant de créer le checkout.
3. Yavsc utilise les paramètres marchands existants
`Authentication:PayPal` (mode sandbox/production et identifiants NVP).
Express Checkout désigne le compte du performer par
`PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID` : le paiement va directement
au performer ; Yavsc ne collecte pas les fonds et ne les reverse pas.
4. PayPal recueille l'accord du donateur puis le renvoie dans Yavsc.
Une confirmation POST avec protection antiforgery déclenche le
paiement, après vérification côté serveur du token, du montant,
de la devise, de la référence et du bénéficiaire. Un simple retour
du navigateur n'est jamais considéré comme une preuve de paiement.
5. Seule une réponse PayPal vérifiée permet d'afficher le don comme payé.
Les notifications IPN reçues sur `/Donation/Notify` sont revalidées
auprès de PayPal, puis rapprochées du bénéficiaire, du montant, de la
devise et de la transaction. Elles permettent aussi de confirmer
ultérieurement un paiement Pending ou une réponse de capture perdue.
`Site:ExternalUrl` doit être l'URL HTTPS publique de Yavsc.Org, utilisée
pour les retours et notifications (jamais le Host fourni par le client).
PayPal doit pouvoir joindre `/Donation/Notify`. Le compte marchand
doit être autorisé par PayPal pour les paiements directs à un tiers ;
valider ce parcours en sandbox avec deux comptes distincts avant mise
en production. Les tests locaux utilisent un serveur PayPal simulé,
pas de véritables transactions.
`PerformerDonations` conserve la référence, le donateur, le performer,
le bénéficiaire figé à la création, le montant et l'état du paiement.
La migration `PerformerPayPalDonations` ajoute ce suivi et l'adresse
PayPal optionnelle du performer. Les profils ayant un historique de
dons ne peuvent pas être supprimés. Les rééditions du profil général
ne remplacent pas la configuration du bénéficiaire.
Le paiement n'est capturé qu'une fois : contrôle de concurrence,
référence de déduplication PayPal (`MSGSUBID`) et transaction unique.
En cas de réponse ambiguë, le don reste en vérification sans nouvelle
capture automatique. Le donateur retrouve son suivi dans **Mon compte >
Mes dons** (`/Donation`). Si l'IPN ne réconcilie pas cet état, l'opérateur
doit vérifier la transaction auprès de PayPal avant toute nouvelle
tentative. Les dons sont facultatifs et n'ouvrent pas droit à un reçu
fiscal émis par cette page.
## À documenter ensuite
- Modèle `ProjetMusical`

View file

@ -16,6 +16,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter |
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/audio-video-calls.md](architecture/audio-video-calls.md) | Plan des appels WebRTC PostIt : SignalR, préférence de masquage IP via TURN et groupes via SFU |
| [architecture/notifications.md](architecture/notifications.md) | Notifications persistées, messages Web et événements temps réel côté serveur, Web et PostIt |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |

View file

@ -0,0 +1,561 @@
# Appels audio-vidéo PostIt — architecture cible
## Statut et périmètre
Ce document décrit l'architecture cible et l'implémentation commencée.
La préférence de confidentialité est persistée et éditable dans le profil.
La signalisation est intégrée au chat, désactivée par défaut. Un premier
transport natif **Android et Desktop Linux sur LAN/VPN** est raccordé :
captures Podcast existantes et SRTP direct, sans libwebrtc ni nouvelle
couche JNI WebRTC. Il ne réalise pas encore la cible Internet, TURN ou groupe.
Il complète la [topologie de PostIt](postit.md) et les
[mécanismes de notification existants](notifications.md).
La cible couvre :
- les appels audio-vidéo bidirectionnels entre deux utilisateurs ;
- un réglage utilisateur booléen persistant permettant de masquer son adresse
IP aux autres participants en imposant TURN ;
- les appels de groupe ;
- les clients Browser, Android et Desktop.
## Première implémentation Android et Desktop Linux : SRTP sur LAN/VPN
Cette tranche réutilise la capture caméra/microphone et les encodeurs des
Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** :
```text
PostIt A ----------- ChatHub authentifié / HTTPS ----------- PostIt B
invitation, consentement,
adresses et clés, fin
PostIt A ========== H.264/AAC, MPEG-TS sur SRTP/UDP ========== PostIt B
dans les deux sens
```
L'implémentation est dans
[LibVlcLanCallMedia](../../src/PostIt/PostIt/Services/LibVlcLanCallMedia.cs),
[LinuxLanCallVideo](../../src/PostIt/PostIt/Services/LinuxLanCallVideo.cs) et
[LanCallViewModel](../../src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs).
Android utilise
[AndroidLanCallMedia](../../src/PostIt/PostIt.Android/Services/AndroidLanCallMedia.cs)
avec les mêmes contrats et clés SRTP.
Les contrôles et les deux surfaces vidéo sont intégrés à la page du chat.
Il n'y a pas de service de salles supplémentaire, d'enregistrement,
de fichier média temporaire ou de moteur WebRTC Android ajouté.
### Activer et essayer
1. Sur le serveur, activer explicitement les deux options :
```json
{
"AudioVideoCalls": {
"Enabled": true,
"EnableLanCalls": true
}
}
```
Les deux valeurs sont `false` par défaut. Les paramètres TURN ne sont
pas nécessaires pour ce mode. Utiliser le ChatHub en **HTTPS** ;
invitation, acceptation et échange de clés LAN sont refusés en HTTP.
Derrière un proxy TLS, configurer correctement le schéma HTTPS transmis
à ASP.NET Core, uniquement depuis les proxys de confiance.
2. Utiliser deux clients Android 8+ ou Desktop Linux. Linux nécessite LibVLC
avec V4L2, PulseAudio, x264, AAC et RTP/SRTP installés. Android réutilise
la bibliothèque VLC embarquée pour les Podcasts et MediaRecorder pour
H.264/AAC. La version Debian 13 a été utilisée pour les essais Linux.
Les deux comptes doivent être présents dans
la même salle existante et ne pas se bloquer mutuellement.
3. Dans « Appel direct (LAN/VPN) », actualiser les périphériques,
sélectionner la caméra, le microphone et une IPv4 privée locale
accessible depuis le pair. Les adresses RFC1918 et de bouclage sont
acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux
essais sur un même hôte, pas à joindre un second poste.
4. Choisir un port UDP **pair entre 1024 et 65534**, par défaut 5004.
Autoriser l'entrée UDP sur ce port **et le suivant** depuis le pair dans
le pare-feu : RTP sur le port pair, RTCP sur le port impair suivant.
Les deux ports doivent être disponibles. Deux clients sur la même
adresse doivent utiliser des paires distinctes.
Aucun pare-feu n'est modifié automatiquement.
5. Saisir le pseudo exact du destinataire, confirmer la divulgation de
l'adresse LAN/VPN, puis appeler. Le destinataire choisit ses propres
périphériques et son adresse, confirme et accepte explicitement.
Aucun accès à la caméra/microphone ne commence avant l'acceptation ;
l'émission attend également la destination validée du pair.
6. Utiliser un **casque** : il n'y a pas d'annulation d'écho acoustique.
« Refuser / Raccrocher », la sortie de page et la perte de SignalR
arrêtent et libèrent les médias. Il n'y a pas de reprise automatique
de capture après reconnexion.
### Capture et cycle de vie Android
- La caméra avant est sélectionnée en priorité dans la liste ; une autre
caméra reste sélectionnable explicitement. Le microphone est obligatoire.
- Les permissions caméra, microphone et notifications sont demandées
après acceptation et réception de l'endpoint distant, avant la capture.
Un refus termine explicitement l'appel, sans tentative de capture de repli.
- Camera2, MediaRecorder et la rotation EGL sont ceux des Podcasts.
Le descripteur du pipe MPEG-TS est lu directement par LibVLC (`fd://`)
puis remultiplexé vers SRTP, **sans réencodage**, HLS ou fichier média.
Le pipe système assure une mémoire bornée ; il n'y a pas de file de
segments ni de lecture de capture par callbacks managés.
- La prévisualisation réutilise la surface EGL existante. Le retour
distant utilise la surface Android LibVLC déjà employée pour les Podcasts.
- Le service de capture partagé affiche une notification d'appel avec
l'action « Raccrocher » ; cette action ne publie aucun Podcast.
Un Podcast et un appel ne peuvent pas posséder la capture simultanément.
- Le passage de PostIt en arrière-plan termine cet appel vidéo ; revenir
dans l'application ne le redémarre pas. La sortie de page et la perte
SignalR conservent la même règle d'arrêt.
- À l'arrêt, le lecteur natif libère d'abord son entrée. Le pipe restant
est drainé pendant la finalisation de MediaRecorder, pour ne pas bloquer
son écrivain, puis caméra, microphone, lecteurs et service sont libérés.
#### Particularité VLC Android : RTCP séparé
L'essai matériel initial a identifié un SIGSEGV dans `libc.__vfprintf`,
appelé par `vlc_memstream_vprintf` puis `sdp_AddAttribute` pendant la
création de la sortie RTP. Le code VLC 3 génère l'attribut sans valeur
`rtcp-mux` avec un format nul ; cette combinaison échoue avec le binaire
Android embarqué. Ce crash est distinct du précédent essai libwebrtc/JNI.
Les appels utilisent donc une paire RTP/RTCP standard, sans `rtcp-mux`,
sur Android **et** Linux. Les médias conservent les mêmes codecs, clés et
port RTP : c'est un changement de configuration du transport, pas
l'ajout d'un moteur WebRTC ou d'une cryptographie propre au projet.
### Confidentialité et limites
- Une clé SRTP aléatoire de 128 bits et un sel de 112 bits sont générés
par appel et par direction avec le générateur cryptographique système.
Seuls les deux appareils ayant accepté échangent un endpoint chacun
via SignalR authentifié. Les représentations diagnostiques masquent
adresse, clé et sel.
- Le chiffrement SRTP est fourni par LibVLC ; les clés passent par
le serveur de signalisation, qui **peut donc les connaître**. Ce n'est
pas un chiffrement de bout en bout à l'insu du serveur.
- Si l'un des comptes impose `HideIpAddressInCalls`, le mode LAN est
**refusé**, y compris si la préférence change avant l'acceptation.
Il n'y a aucun repli direct contournant cette préférence.
- Pas d'ICE, de STUN, de TURN, de traversée NAT automatique, de groupe,
d'interopérabilité Browser, de négociation de codecs ni
d'adaptation de débit. Ce mode exige un LAN/VPN routable entre les pairs.
- H.264 à 30 images/s, cible 1 200 kbit/s ; AAC mono 48 kHz, 96 kbit/s.
Ces réglages ne constituent pas une garantie de latence ni de débit.
Le retour local n'émet pas le son du microphone.
- L'absence de première réception audio **et** vidéo provoque l'arrêt
après 30 secondes ; les erreurs natives sont affichées et terminent
l'appel. Après réception initiale, les compteurs LibVLC ne permettent
pas ici de diagnostiquer précisément une panne média silencieuse :
l'utilisateur peut raccrocher, et la perte de SignalR arrête toujours
la capture. Pas encore de mesure de qualité ou de latence affichée.
### Validation effectuée et restante
Les tests natifs utilisent deux moteurs LibVLC réels, un fichier audio-vidéo
synthétique fourni explicitement et de vrais sockets UDP de bouclage :
- décodage bidirectionnel de trames vidéo et d'échantillons audio PCM ;
- refus de décodage avec une mauvaise clé, sans casser l'autre direction ;
- même échange avec la branche de prévisualisation locale activée
(sortie factice dans ce test sans fenêtre).
- entrée MPEG-TS déjà encodée à travers un pipe, sans transcodage.
Les tests de signalisation couvrent consentement, sélection d'un seul
appareil, confidentialité, HTTPS, validation des endpoints et arrêt.
Les tests Avalonia sans fenêtre couvrent l'ordre invitation/acceptation/
capture, le refus, les événements tardifs, l'interruption, les erreurs média
et l'annulation d'un démarrage natif en attente. Ils vérifient aussi les
hooks Android, le choix initial de caméra avant, la configuration lors
d'une invitation entrante et l'arrêt lors du passage en arrière-plan.
Pour exécuter les tests média, définir `POSTIT_TEST_MEDIA` avec le chemin
d'un MP4 H.264/AAC de test (640 × 480), puis :
```sh
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-class '*LinuxLanCallMediaTests'
```
Sans cette entrée explicite, ces tests sont ignorés, pas remplacés par un
succès fictif. Les tests de contrôle s'exécutent sans caméra.
Le test de pipe utilise `POSTIT_TEST_TS`, un MPEG-TS H.264/AAC explicite.
Une activité **Debug uniquement**,
[AndroidLanCallTestActivity](../../src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs),
teste l'adaptateur réel sur téléphone : caméra avant/microphone, surfaces
de retour et prévisualisation attachées, deux moteurs SRTP en UDP de
bouclage, retour synthétique et rejet d'une mauvaise clé. Elle n'utilise
ni compte ni API ; elle ne valide donc pas à elle seule un appel SignalR
entre deux comptes. L'activité n'est pas compilée dans l'APK Release.
Pour la lancer sur un appareil de développement dont les permissions
ont été accordées, installer l'APK Debug et ses assemblies via
`dotnet build -t:Install`, puis copier un MPEG-TS synthétique de 40 secondes,
640 × 480 avec audio AAC, sous `files/lan-call-fixture.ts` dans les données
privées de l'application. Exécuter :
```sh
adb shell am start -S -n fr.pschneider.postit/fr.pschneider.postit.LanCallTestActivity
adb shell run-as fr.pschneider.postit cat files/lan-call-test-result.txt
# Variante : ajouter --es wrongKey true à la commande am start.
```
Le résultat ne devient `PASS` qu'après vérification des médias et nettoyage
complet. Le test ne produit aucun enregistrement de caméra.
Sur le téléphone Android 15 arm64 connecté, l'essai positif a décodé
158 trames caméra et 399 360 échantillons audio en environ dix secondes,
avec un retour audio/vidéo décodé simultanément. L'essai avec mauvaise clé
a produit zéro trame et zéro échantillon dans la direction concernée,
tout en conservant le retour. Les deux essais ont terminé leur nettoyage,
sans service de capture restant. La capture Podcast antérieure a également
été retestée sur caméra avant : quatre segments, environ 11,8 secondes,
audio AAC présent, capture en arrière-plan puis arrêt par notification.
**La caméra physique, le rendu natif visible dans deux applications, le
son audible et un appel entre deux machines restent à valider côté Linux** :
aucune caméra n'était disponible sur cet hôte. La capture matérielle Android
est testée, mais un appel Android ↔ Linux ou entre deux téléphones distincts
sur LAN/VPN et la qualité acoustique restent à valider. Les essais de
bouclage ne prouvent pas une latence conversationnelle.
## Choix des protocoles
La table suivante demeure la **cible multi-plateforme initiale**, distincte
du premier transport Linux LAN/VPN décrit ci-dessus.
| Besoin | Choix | Rôle |
|---|---|---|
| Médias temps réel | WebRTC | Audio-vidéo bidirectionnel à faible latence, adaptation au réseau |
| Chiffrement du transport média | DTLS-SRTP | Établissement des clés et protection des médias |
| Signalisation | SignalR sur HTTPS | Invitation, acceptation/refus, négociation SDP, candidats ICE, fin d'appel |
| Connectivité | ICE, STUN et TURN | Sélection du chemin réseau, découverte d'adresse et relais |
| Audio | Opus | Codec privilégié pour la voix |
| Vidéo | VP8 ou H.264 | Codec négocié selon les capacités réelles des clients |
| Appels de groupe | SFU compatible WebRTC | Réception et redistribution sélective des flux |
SignalR transporte les messages de contrôle, **jamais les flux audio-vidéo**.
La signalisation doit réutiliser le [ChatHub existant](../../src/Yavsc.Server/Hubs/ChatHub.cs),
déjà exposé par [Yavsc.Api](../../src/Yavsc.Api/Program.cs), et ses salles de
discussion. Il n'est pas nécessaire de créer un second service de salles.
Les contrats d'appel et leurs autorisations restent à définir explicitement :
l'appartenance à une salle ne vaut pas acceptation d'un appel.
Le choix des bibliothèques natives, du SFU et des codecs effectivement
disponibles reste à valider par un prototype multi-plateforme.
## Topologies média
### Appels à deux
```text
PostIt A ---- SignalR / HTTPS ---- Serveur ---- SignalR / HTTPS ---- PostIt B
Invitation, acceptation, SDP, candidats ICE, fin
PostIt A ================= WebRTC direct ========================= PostIt B
ou
PostIt A ================= relais TURN =========================== PostIt B
```
Privilégier un chemin direct lorsque les préférences de confidentialité le
permettent. Prévoir TURN dès la première version : STUN seul ne suffit pas sur
certains réseaux mobiles, NAT ou réseaux d'entreprise.
Déployer par exemple coturn, avec des transports UDP et des solutions de
repli TCP/TLS, dont TURN/TLS sur le port 443. Ce dernier améliore la
connectivité sans garantir la traversée de tous les pare-feu et proxys.
### Appels de groupe
```text
PostIt A ---- WebRTC, éventuellement via TURN ----+
PostIt B ---- WebRTC, éventuellement via TURN ----+---- SFU
PostIt C ---- WebRTC, éventuellement via TURN ----+
```
Utiliser un **SFU**, plutôt qu'un maillage de connexions entre participants :
chaque client envoie ses pistes au serveur, qui redistribue les flux utiles.
Cela évite de multiplier les envois montants avec le nombre de participants.
Prévoir la sélection des pistes reçues et, selon les capacités des clients,
le simulcast ou le codage vidéo scalable pour adapter débit et résolution.
Le SFU ne remplace pas TURN : un client peut avoir besoin d'un relais pour
atteindre le SFU, ou l'imposer par sa préférence de confidentialité.
Les contrats de signalisation doivent représenter dès le départ un appel et
ses participants, sans être limités à un couple appelant/appelé. Pour ajouter
un troisième participant à un appel direct, établir les connexions au SFU,
puis fermer les connexions directes après confirmation du nouveau transport.
En cas d'échec, conserver l'appel à deux et signaler l'échec de l'ajout.
Une topologie SFU pour tous les appels reste une alternative à évaluer si la
simplicité d'exploitation prime sur le coût serveur.
## Préférence persistante de masquage d'adresse IP
### Contrat fonctionnel
Le booléen de compte `HideIpAddressInCalls` est persisté côté serveur et
accessible en lecture/écriture uniquement par
l'utilisateur concerné via `GET /api/v1/account/me` et
`PUT /api/v1/account/me`, ainsi que dans « Mon profil » de PostIt.
Le réglage est partagé entre ses appareils ; une préférence locale seule
ne suffit pas.
Valeur initiale : `false`, pour les nouveaux comptes et les comptes existants
après migration. Le champ de mise à jour est nullable : son absence ou `null`
conserve la valeur existante, notamment pour les anciens clients ; `false`
explicite désactive la préférence.
`false` autorise le chemin direct mais n'interdit pas TURN lorsqu'il est
nécessaire à la connectivité. `true` impose un transport relayé.
Présenter le réglage comme « Masquer mon adresse IP aux autres participants »,
en précisant le surcoût potentiel en latence et en bande passante serveur.
Il ne procure pas l'anonymat : les services contactés, notamment l'API, le
serveur de signalisation et TURN, peuvent toujours connaître l'adresse IP.
La migration EF Core `HideIpAddressInCalls`, dans le projet Yavsc.Org, ajoute
la colonne booléenne non nullable à `AspNetUsers`. Elle doit être déployée
avant de servir les nouvelles lectures et écritures de profil. Cette étape
n'installe ni TURN ni SFU et n'implémente pas encore les règles de négociation
décrites ci-dessous.
### Application à la négociation
- Charger la préférence avant de créer le transport média. Si sa lecture
échoue, signaler l'erreur et bloquer l'établissement plutôt que supposer
que le masquage est désactivé.
- Lorsque le réglage est activé, configurer une politique ICE **relay-only**
(`iceTransportPolicy: "relay"` dans le navigateur, équivalent natif ailleurs)
avant la collecte des candidats.
- Ne publier aucun candidat local `host` ou `srflx`, ni aucune adresse locale
dans les descriptions SDP ou messages de signalisation. Utiliser les API du
moteur WebRTC et vérifier les descriptions produites, plutôt qu'une
réécriture ad hoc du SDP.
- Appliquer cette contrainte aux offres, réponses, renégociations,
redémarrages ICE et transitions vers le SFU.
- Le masquage d'un participant impose le relais de **son propre transport**,
même si les autres participants n'ont pas activé cette préférence.
- Si TURN est indisponible ou qu'aucune paire de candidats relayée n'aboutit,
afficher une erreur explicite ; **aucun repli direct automatique**.
Le serveur détermine la politique attendue depuis la préférence persistée,
contrôle les messages de signalisation et distribue les paramètres autorisés.
Le client applique la politique ICE ; les tests doivent vérifier le chemin
réellement sélectionné, pas seulement la configuration demandée.
Une modification pendant un appel doit être prise en charge explicitement :
l'activation suspend l'émission et remplace le transport avant de reprendre
les médias en mode relayé. L'interface ne doit pas annoncer le masquage tant
que ce remplacement n'est pas confirmé. Elle précise qu'une adresse déjà
divulguée auparavant ne peut pas être retirée aux participants.
La désactivation peut attendre l'appel suivant, sans abandonner le relais
en cours.
## Intégration dans PostIt
Séparer le cycle de vie partagé de l'appel du moteur média :
- code partagé : invitation, sonnerie, acceptation/refus, participants,
préférences, état de connexion, coupure micro/caméra et raccrochage ;
- Browser : API WebRTC du navigateur via interop JavaScript ;
- Android et Desktop : moteur WebRTC natif et intégration de la capture,
des périphériques et du rendu dans Avalonia.
Le prototype doit vérifier les bindings maintenus, leurs licences, les
architectures processeur supportées, les codecs et l'intégration Avalonia.
Ne pas supposer qu'une bibliothèque .NET couvre à elle seule la capture,
l'annulation d'écho et le rendu sur toutes les plateformes.
Prévoir les permissions caméra/micro, les changements de périphérique,
les interruptions Android, la reconnexion et le redémarrage ICE.
Les appels entrants en arrière-plan nécessitent une intégration aux
notifications système et au cycle de vie Android ; une connexion SignalR
active ne peut pas être présumée.
## Autorisations, confidentialité et exploitation
- Authentifier la signalisation et vérifier côté serveur l'appartenance à
l'appel pour chaque commande et événement, y compris rejoindre un groupe,
inviter, publier une piste et recevoir les flux.
- Prévoir des invitations expirables et gérer les réponses concurrentes de
plusieurs appareils sans accepter plusieurs fois le même participant.
- Délivrer des identifiants TURN temporaires et des autorisations SFU limitées
à l'appel et au participant ; aucun secret permanent dans les clients.
- Limiter les invitations, les participants et les débits selon une politique
produit à définir ; surveiller la capacité et le coût TURN/SFU.
- Ne pas journaliser systématiquement SDP, candidats ICE ou identifiants TURN.
Préférer des métriques de qualité et des identifiants techniques d'appel.
- Distinguer chiffrement du transport et chiffrement de bout en bout :
en connexion directe ou via TURN, les médias restent chiffrés entre les deux
clients ; avec un SFU classique, DTLS-SRTP se termine au SFU. Un chiffrement
de bout en bout des groupes demanderait un mécanisme supplémentaire et une
gestion des clés à concevoir, sans être promis par ce plan.
## Étapes proposées
1. **Prototype multi-plateforme** : valider capture, rendu, codecs et WebRTC
entre Browser, Android et Desktop, y compris via TURN.
2. **Appels à deux** : signalisation authentifiée, cycle de vie d'appel,
coturn, préférence persistante et politique relay-only opérationnelle.
3. **Appels de groupe** : sélectionner et intégrer le SFU, autoriser les
participants, gérer les pistes et la transition depuis un appel direct.
4. **Fiabilisation** : arrière-plan Android, réseau mobile, reprise après
interruption, accessibilité des contrôles et observabilité.
Les appels de groupe font partie de la cible finale, pas d'une simple
extension facultative. Leur modèle de participants doit être anticipé dès
la signalisation des premiers appels à deux.
## Critères de validation
- Audio et vidéo bidirectionnels sur les combinaisons de plateformes ciblées.
- Appel direct autorisé quand le réglage est désactivé et relais disponible
lorsque le réseau l'exige.
- Préférence conservée après reconnexion et relue sur un autre appareil.
- Avec masquage activé, absence d'adresses locales/publiques propres au client
dans la signalisation transmise aux pairs et sélection effective d'un
candidat local `relay`, confirmée par les statistiques WebRTC.
- Aucun repli direct si TURN échoue, y compris après redémarrage ICE,
changement de réseau ou ajout d'un participant.
- En groupe, contrôle d'accès aux pistes, départ et arrivée de participants,
adaptation de qualité et respect individuel de la politique TURN.
- Activation du masquage pendant un appel : suspension des médias,
remplacement vérifié du transport et retour d'état fidèle dans l'interface.
- Échecs de permissions, de signalisation, de TURN et du SFU visibles,
ressources caméra/micro et connexions libérées à la fin de l'appel.
## Première tranche intégrée : signalisation Android et Desktop Linux
Le périmètre initial retenu est **Android + Desktop Linux**. Il n'ajoute
aucun serveur ni aucune salle : les méthodes suivantes appartiennent au
[ChatHub](../../src/Yavsc.Server/Hubs/ChatHub.Calls.cs) existant.
Les [contrats partagés](../../src/Yavsc.Abstract/Chat/Calls/CallContracts.cs)
sont utilisés par le serveur et PostIt.
| Méthode | Effet |
|---|---|
| `InviteCall(roomName, callee)` | Invite un autre compte présent dans la salle et retourne un identifiant d'appel |
| `AcceptCall(callId)` | Accepte sur un seul appareil, notifie les deux pairs et ferme l'invitation sur les autres appareils |
| `SendCallSignal(callId, signal)` | Transmet l'offre, la réponse ou un candidat ICE uniquement à l'autre appareil accepté |
| `EndCall(callId)` | Annule, refuse ou termine l'appel depuis un appareil autorisé |
Événements : `CallInvitation`, `CallAccepted`, `CallSignal(callId, signal)`
et `CallEnded`. **Accepté ne veut pas dire connecté** : l'événement accepté
exprime le consentement et fournit la politique ICE et les paramètres
STUN/TURN, pas une réussite de l'échange média.
Le serveur vérifie les comptes, la présence dans la salle et le blocage
entre utilisateurs. Les appareils d'un compte reçoivent l'invitation ;
seul le premier qui accepte peut ensuite émettre la réponse et les
candidats. L'appelant reste lié à sa connexion d'origine. Les médias
ne sont jamais diffusés au groupe SignalR de la salle.
La préférence persistée est lue à l'invitation et relue à l'acceptation.
Pour cette première version à deux, si l'un des comptes demande le
masquage, **les deux transports doivent être relayés**, règle conservatrice
plus stricte que la politique individuelle cible. L'acceptation ne peut
pas diminuer la protection annoncée à l'invitation. Sans TURN configuré,
un appel imposant le relais échoue explicitement.
La signalisation refuse les candidats non relayés, les adresses associées
non masquées et les adresses directes dans les champs SDP contrôlés lorsque
le relais est imposé. Ces contrôles de forme ne constituent pas un audit
exhaustif de toutes les extensions SDP possibles : la non-divulgation doit
encore être prouvée avec les SDP et statistiques des moteurs réellement
intégrés. Aucun SDP n'est réécrit.
L'activation du masquage ou le changement de pseudo par l'API de profil
termine l'appel existant ; le client devra libérer les médias et proposer
un nouvel appel. La renégociation transparente du transport n'est pas
implémentée. La déconnexion d'un pair sélectionné, le départ ou l'exclusion
de la salle terminent également l'appel. Pas de reprise automatique après
reconnexion : elle crée une nouvelle identité de connexion.
Les appels sont temporaires, en mémoire et **sur une seule instance API**.
Une invitation expire après 45 secondes, une session acceptée après une
heure ; les identifiants TURN expirent cinq minutes plus tard. Cette borne
initiale ne remplace pas un futur renouvellement d'identifiants pour les
appels longs. Un compte ne peut avoir qu'un appel réservé ou accepté à la
fois. Le registre est limité à 128 appels et 256 candidats par appel.
La tranche ne permet qu'une offre initiale de l'appelant et une réponse ;
les redémarrages ICE et renégociations restent à implémenter.
Configuration serveur, section `AudioVideoCalls` :
- `Enabled` : `false` par défaut, à conserver tant que les moteurs ne sont
pas validés ;
- `StunUrls` : tableau d'URI `stun:` ou `stuns:` ;
- `TurnUrls` : tableau d'URI `turn:` ou `turns:`, avec transport optionnel ;
- `TurnSharedSecret` : secret REST coturn, **uniquement côté serveur**,
à fournir par le gestionnaire de secrets ou la variable d'environnement
`AudioVideoCalls__TurnSharedSecret`, jamais dans le dépôt.
Les credentials TURN temporaires sont calculés côté serveur avec le
format REST coturn (expiration, nonce aléatoire et HMAC-SHA1). Aucun secret
de longue durée n'est envoyé aux clients. Aucun service TURN n'a été
déployé ou configuré par cette tranche.
Le [transport PostIt](../../src/PostIt/PostIt/Services/CallSignalingClient.cs)
utilise la connexion authentifiée du `ChatViewModel`, sans seconde connexion
ni interrogation HTTP. Il expose les commandes et événements typés et
signale les interruptions ; il ne capture aucun média. L'interface ne
propose pas encore de bouton d'appel ; une éventuelle invitation est
signalée comme non utilisable faute de moteur natif, sans acceptation
automatique.
### Travail restant pour la cible Internet multi-plateforme
- Android : l'approche Java/JNI libwebrtc a été écartée à la demande du
projet ; aucun adaptateur libwebrtc expérimental n'est conservé.
L'adaptateur SRTP LAN réutilise désormais la capture Podcast ; la solution
compatible avec la cible Internet reste à choisir.
- Desktop Linux : le transport SRTP LAN est raccordé, mais ne remplace pas
les validations matérielles ni une solution de connectivité Internet.
- Côté partagé : relier consentement, états réels du moteur, mise en
sourdine, arrêt et libération des ressources à la signalisation.
- Valider un appel Android ↔ Desktop Linux avec caméra avant et microphone
réels, rendu et audio distants, VP8/Opus, puis transport TURN sélectionné
dans les statistiques, sans repli direct lorsque le masquage est demandé.
Les tests de cette tranche portent sur les contrats, autorisations, courses
d'acceptation, expirations, limites, interruption et préférence persistée.
La compilation des applications Android et Desktop ne prouve pas le
fonctionnement de WebRTC natif : le crash antérieur n'est pas résolu.
## Retour sur l'essai Android du 25 octobre 2026
Le laboratoire indépendant a été supprimé ; il ne constitue pas une base
de salles de discussion ni une implémentation de PostIt. La suite doit
réutiliser le ChatHub existant pour la signalisation.
L'essai en échec opposait un téléphone Android 15 arm64 à Chromium,
en mode TURN uniquement, avec signalisation HTTP locale et TURN/TCP
accessibles par redirections USB adb. Il n'utilisait ni ChatHub ni
PostIt.Desktop Linux.
Les observations doivent être distinguées :
- Camera2, la capture audio et un premier rendu EGL local ont démarré.
Cela ne valide ni le transport ni le rendu distant.
- Le processus Android a subi un SIGABRT dans
`libjingle_peerconnection_so.so`. Une première pile passait par
`PeerConnection.nativeAddIceCandidate` ; après modification du traitement
des candidats, un arrêt natif persistait sur `network_thread`.
- Le test a attendu un échange média qui n'est jamais arrivé. Le dernier
rapport Browser indiquait `connection: new`, zéro octet audio/vidéo et
aucune paire ICE sélectionnée ; aucun rapport Android n'a validé l'appel.
- Le problème a été reproduit avec les SDK libwebrtc 150.7871.04 et
144.7559.15. L'attente des descriptions distantes, la résolution du
`sdpMid` et l'ajout asynchrone des candidats n'ont pas suffi.
Il s'agit donc d'un **crash natif pendant la tentative d'établissement**,
pas d'un refus de codec ou d'un échec TURN identifié. Le dernier appel JNI
visible ne prouve pas la cause du crash. Sans message fatal détaillé ni
pile native symbolisée, un défaut du code expérimental, de son intégration
Java/JNI ou de libwebrtc reste à départager. Rien ne démontre une
impossibilité de WebRTC sur Android ou une défaillance du ChatHub.
Les essais Browser/GStreamer réussis utilisaient des sources synthétiques
et un décodage sans affichage côté GStreamer : ils ne valident pas
PostIt.Desktop Linux, ses périphériques réels ou son rendu Avalonia.
La validation multi-plateforme demeure incomplète.

View file

@ -78,24 +78,62 @@ pour éviter de monter un host complet.
Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) :
```bash
dotnet test --project src/test/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj \
--filter-class '*PodcastWebTests' --filter-class '*PerformerDonationTests'
dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests'
--filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' \
--filter-class '*PodcastReplayTests' --filter-class '*PodcastScheduleTests' \
--filter-class '*PerformerDonationSettingsTests'
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests'
--filter-class '*PodcastTests' --filter-class '*PodcastReplayTests' \
--filter-class '*YavscApiClientTests' --filter-class '*PodcastNativeCaptureTests' \
--filter-class '*PerformerDonationSettingsTests'
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-method '*Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal'
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-class '*PodcastScheduleTests'
```
Les tests serveur couvrent HTTP/authentification, proprietaire, sequences,
reprises idempotentes, limites exactes, concurrence et persistance des replays
apres interruption/redemarrage, dans des dossiers temporaires isoles.
Les tests de configuration PayPal couvrent la lecture privee, la creation,
la validation avant toute sauvegarde, l'effacement, la compatibilite des anciens
clients, ainsi que le chargement, la liaison du champ et l'enregistrement PostIt.
Les tests `PodcastScheduleTests` couvrent la preparation persistante dans
`LiveFlow`, la confidentialite, l'annonce/unpublication, les controles
proprietaire, la validation et le demarrage manuel idempotent.
La preparation PostIt et les tests de capture headless ont ete verifies
separement : leur selection groupee peut rester bloquee alors que chaque
test cible reussit isole.
Installer FFmpeg 5+ et ffprobe pour ces tests serveur : ils generent une
source H.264/AAC synthetique de quatre secondes, verifient le remuxage reel
(pistes, duree, faststart, decodage et seek a deux secondes), puis les reponses
HTTP authentifiees GET/HEAD/206/416 du worker. Aucune camera ni microphone
n'est utilise. Les tests client couvrent les plages exactes et bornees,
les seeks avant/arriere/EOF, l'annulation, le renouvellement des jetons
et le seuil exact d'auto-hide de trois secondes.
Pour tester aussi le decodage MP4 et les seeks avant/arriere dans LibVLC,
definir `POSTIT_TEST_REPLAY` sur un MP4 H.264/AAC local de 8 a 30 secondes
(LibVLC installe sur la machine de test). Le test natif est ignore sinon.
Verifier manuellement les overlays sur Desktop et Android : pause/reprise,
glissement de la barre, masquage apres trois secondes, reapparition au
toucher/survol et nettoyage apres navigation ou mise en arriere-plan.
Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`.
Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir
cette variable sur un fichier video local d'environ huit secondes contenant
une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la
reprise apres acquittement perdu, le dernier segment apres arret et le
decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni
par l'application (le fichier d'entree peut provenir de n'importe quelle source).
decodage authentifie du replay MPEG-TS. FFmpeg n'est pas une dependance
d'execution de PostIt (le serveur MP4, lui, en a besoin).
Les tests materiels camera/microphone Windows/Linux restent manuels.
Les deux variantes du test d'encodage HLS (avec/sans apercu) necessitent
egalement `ffprobe` pour verifier independamment les pistes H.264 et AAC
du premier segment. La variante avec apercu attend une image decodee
par LibVLC dans un tampon memoire, sans ouvrir de camera ni diffuser le son
sur les haut-parleurs. Le test de page headless verifie aussi le binding
du controle d'apercu et son retrait avant liberation de la capture.
### Rotation de la capture Android
@ -122,6 +160,9 @@ Attendre la fin du test : `RUNNING` doit devenir `PASS`. Il encode quatre
quadrants colores en MPEG-TS pour 0, 90, 180 et 270 degres, decode une image et
verifie les dimensions (640x480 ou 480x640) et les couleurs aux quatre positions.
La tolerance de chaque composante RGB est de 40 pour tenir compte de H.264.
Il verifie aussi les couleurs RGBA de l'apercu OpenGL (tolerance 5), les
bandes noires sans deformation et le detachement/rattachement de sa surface
pendant l'encodage, toujours pour les quatre rotations.
Une erreur de rendu, d'encodage, de decodage ou de comparaison produit `FAIL`
avec l'exception. Cette activite est exclue des builds Release.

View file

@ -8,6 +8,20 @@
"picket"
],
"rollForward": false
},
"gitversion.tool": {
"version": "6.8.2",
"commands": [
"dotnet-gitversion"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.13",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View file

@ -48,6 +48,10 @@ const bundleNames = [
// of bootstrap, jquery-ui, etc., which are NOT touched by this
// toolchain).
const staticAssets = [
{
src: 'node_modules/hls.js/dist/hls.light.min.js',
dst: 'hls.light.min.js',
},
{
src: 'node_modules/@aspnet/signalr/dist/browser/signalr.min.js',
dst: 'signalr.min.js',

View file

@ -5,6 +5,7 @@
"description": "Yavsc front-end toolchain (esbuild bundles for Yavsc.Org).",
"scripts": {
"build:js": "node esbuild.config.mjs",
"build:podcasts": "node -e \"require('node:fs').copyFileSync('node_modules/hls.js/dist/hls.light.min.js', 'src/Yavsc.Org/wwwroot/js/hls.light.min.js')\"",
"build:js:watch": "node esbuild.config.mjs --watch",
"clean:js": "node esbuild.config.mjs --clean"
},
@ -13,6 +14,7 @@
"bootstrap": "^4.6.2",
"dropzone": "^5.9.3",
"eonasdan-bootstrap-datetimepicker": "^4.17.49",
"hls.js": "1.6.13",
"jquery": "^3.7.1",
"jquery-timepicker": "^1.3.3",
"jquery-ui": "^1.13.3",

View file

@ -21,9 +21,14 @@ internal static class PlatformBootstrap
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
Platform.CreateVideoSurface = (player, replay) => new AndroidVideoSurface(player, replay);
if (System.OperatingSystem.IsAndroidVersionAtLeast(26))
{
Platform.GetLanCallDevices = AndroidLanCallMedia.GetDevices;
Platform.CreateLanCallMedia = (address, port) =>
System.OperatingSystem.IsAndroidVersionAtLeast(26)
? new AndroidLanCallMedia(address, port)
: throw new System.PlatformNotSupportedException("Android 8 requis pour les appels LAN.");
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;
Platform.RequestPodcastPermissionsAsync = AndroidPodcastCapture.RequestPermissionsAsync;
Platform.CreatePodcastCaptureAsync = AndroidPodcastCapture.StartAsync;

View file

@ -9,10 +9,10 @@
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
@ -32,6 +32,7 @@
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidCameraFrameRotationTestActivity.cs" Link="Tests\AndroidCameraFrameRotationTestActivity.cs" />
<Compile Include="..\PostIt.Tests\AndroidLanCallTestActivity.cs" Link="Tests\AndroidLanCallTestActivity.cs" />
</ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).

View file

@ -37,9 +37,17 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
private readonly Action<Exception> _failed;
private readonly float[] _textureMatrix = new float[16];
private readonly CameraFrameClock _clock = new();
private readonly object _lifecycle = new();
private EGLDisplay? _display;
private EGLConfig? _config;
private EGLContext? _context;
private EGLSurface? _window;
private EGLSurface? _previewWindow;
private Action<Exception>? _previewFailed;
private int _previewWidth;
private int _previewHeight;
private int _outputWidth;
private int _outputHeight;
private SurfaceTexture? _texture;
private FrameListener? _listener;
private Surface? _cameraSurface;
@ -98,6 +106,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
var count = new int[1];
if (!EGL14.EglChooseConfig(_display, attributes, 0, configs, 0, 1, count, 0) || count[0] == 0)
throw EglError("Configuration EGL pour l'encodeur");
_config = configs[0];
_context = EGL14.EglCreateContext(_display, configs[0], EGL14.EglNoContext,
[EGL14.EglContextClientVersion, 2, EGL14.EglNone], 0);
if (_context is null || _context.Equals(EGL14.EglNoContext))
@ -124,7 +133,9 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
BindAttribute("textureCoordinate", _coordinates);
_matrixLocation = GLES20.GlGetUniformLocation(_program, "textureMatrix");
GLES20.GlUniform1i(GLES20.GlGetUniformLocation(_program, "cameraTexture"), 0);
GLES20.GlViewport(0, 0, rotation is 90 or 270 ? height : width, rotation is 90 or 270 ? width : height);
_outputWidth = rotation is 90 or 270 ? height : width;
_outputHeight = rotation is 90 or 270 ? width : height;
GLES20.GlViewport(0, 0, _outputWidth, _outputHeight);
CheckGl();
_texture = new SurfaceTexture(_textureId);
@ -136,11 +147,33 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
internal Task StartAsync() => InvokeAsync(() => _rendering = true);
internal Task SetPreviewAsync(Surface? surface, int width = 0, int height = 0, Action<Exception>? failed = null)
{
lock (_lifecycle)
{
if (_disposed) return Task.CompletedTask;
return InvokeAsync(() =>
{
ReleasePreview();
if (surface is null) return;
_previewWindow = EGL14.EglCreateWindowSurface(_display, _config, surface, [EGL14.EglNone], 0);
if (_previewWindow is null || _previewWindow.Equals(EGL14.EglNoSurface))
throw EglError("Creation de la surface d'apercu");
_previewWidth = width;
_previewHeight = height;
_previewFailed = failed;
});
}
}
private void DrawFrame(SurfaceTexture texture)
{
if (!_rendering) return;
try
{
if (!EGL14.EglMakeCurrent(_display, _window, _window, _context))
throw EglError("Activation de l'encodeur");
GLES20.GlViewport(0, 0, _outputWidth, _outputHeight);
texture.UpdateTexImage();
var presentationTime = _clock.GetPresentationTime(texture.Timestamp, Java.Lang.JavaSystem.NanoTime());
if (presentationTime is null) return;
@ -163,6 +196,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
throw EglError("Horodatage de l'image camera");
if (!EGL14.EglSwapBuffers(_display, _window))
throw EglError("Envoi de l'image a l'encodeur");
DrawPreview();
}
catch (Exception ex)
{
@ -171,6 +205,47 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
}
}
private void DrawPreview()
{
if (_previewWindow is null || _previewWidth <= 0 || _previewHeight <= 0) return;
try
{
if (!EGL14.EglMakeCurrent(_display, _previewWindow, _previewWindow, _context))
throw EglError("Activation de l'apercu");
GLES20.GlClearColor(0, 0, 0, 1);
GLES20.GlClear(GLES20.GlColorBufferBit);
var scale = Math.Min((double)_previewWidth / _outputWidth, (double)_previewHeight / _outputHeight);
var width = Math.Max(1, (int)(_outputWidth * scale));
var height = Math.Max(1, (int)(_outputHeight * scale));
GLES20.GlViewport((_previewWidth - width) / 2, (_previewHeight - height) / 2, width, height);
GLES20.GlDrawArrays(GLES20.GlTriangleStrip, 0, 4);
CheckGl();
if (!EGL14.EglSwapBuffers(_display, _previewWindow))
throw EglError("Affichage de l'apercu");
}
catch (Exception ex)
{
var failed = _previewFailed;
global::Android.Util.Log.Error("PostIt.Podcast", $"Apercu indisponible : {ex}");
ReleasePreview();
failed?.Invoke(ex);
}
}
private void ReleasePreview()
{
if (_previewWindow is not null)
{
// Never leave a surface current when its Android holder is about to destroy it.
EGL14.EglMakeCurrent(_display, _window, _window, _context);
if (!_previewWindow.Equals(EGL14.EglNoSurface))
EGL14.EglDestroySurface(_display, _previewWindow);
_previewWindow.Dispose();
_previewWindow = null;
}
_previewFailed = null;
}
private Task InvokeAsync(Action action)
{
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@ -257,9 +332,14 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { InvokeAsync(Release).GetAwaiter().GetResult(); }
Task release;
lock (_lifecycle)
{
if (_disposed) return;
_disposed = true;
release = InvokeAsync(Release);
}
try { release.GetAwaiter().GetResult(); }
finally
{
_thread.QuitSafely();
@ -272,6 +352,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
private void Release()
{
_rendering = false;
ReleasePreview();
_texture?.SetOnFrameAvailableListener(null);
_cameraSurface?.Release();
_cameraSurface?.Dispose();
@ -293,6 +374,7 @@ internal sealed class AndroidCameraFrameRotator : IDisposable
EGL14.EglTerminate(_display);
}
_window?.Dispose();
_config?.Dispose();
_context?.Dispose();
_display?.Dispose();
}

View file

@ -0,0 +1,90 @@
using System;
using Android.Views;
using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
namespace PostIt.Android.Services;
internal sealed class AndroidCapturePreview : Grid, IDisposable
{
private readonly PreviewHost _host;
private readonly TextBlock _status = new() { TextWrapping = Avalonia.Media.TextWrapping.Wrap };
internal AndroidCapturePreview(AndroidCameraFrameRotator rotator)
{
RowDefinitions = new RowDefinitions("*,Auto");
_host = new PreviewHost(rotator, ReportFailure);
Children.Add(_host);
SetRow(_status, 1);
Children.Add(_status);
}
private void ReportFailure(Exception error)
{
global::Android.Util.Log.Error("PostIt.Podcast", $"Apercu indisponible : {error}");
Dispatcher.UIThread.Post(() => _status.Text = $"Apercu indisponible : {error.Message}. La capture continue.");
}
public void Dispose() => _host.Dispose();
private sealed class PreviewHost(AndroidCameraFrameRotator rotator, Action<Exception> failed)
: NativeControlHost, IDisposable
{
private SurfaceView? _view;
private SurfaceCallback? _callback;
private bool _disposed;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
var activity = MainActivity.Current
?? throw new InvalidOperationException("Activite Android indisponible pour l'apercu.");
_view = new SurfaceView(activity);
_callback = new SurfaceCallback(this);
_view.Holder!.AddCallback(_callback);
return new AndroidViewControlHandle(_view);
}
private void SetSurface(ISurfaceHolder holder, int width, int height)
{
if (_disposed) return;
try { rotator.SetPreviewAsync(holder.Surface, width, height, failed).GetAwaiter().GetResult(); }
catch (Exception ex) { failed(ex); }
}
private void Detach()
{
try { rotator.SetPreviewAsync(null).GetAwaiter().GetResult(); }
catch (Exception ex) { failed(ex); }
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
Detach();
if (_callback is not null)
{
_view?.Holder?.RemoveCallback(_callback);
_callback.Dispose();
_callback = null;
}
_view = null;
base.DestroyNativeControlCore(control);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Detach();
}
private sealed class SurfaceCallback(PreviewHost owner) : Java.Lang.Object, ISurfaceHolderCallback
{
public void SurfaceCreated(ISurfaceHolder holder) { }
public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Format format, int width, int height)
=> owner.SetSurface(holder, width, height);
public void SurfaceDestroyed(ISurfaceHolder holder) => owner.Detach();
}
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Android.Services;
[SupportedOSPlatform("android26.0")]
internal sealed class AndroidLanCallMedia : ILanCallMedia
{
private readonly LibVlcLanCallMedia _media;
private readonly ContentControl _local = new();
private readonly AndroidVideoSurface _remote;
private readonly CancellationTokenSource _lifetime = new();
private readonly TaskCompletionSource _captureEnded = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Task _receiving;
private IPodcastCapture? _capture;
private bool _disposed;
public Control LocalVideo => _local;
public Control RemoteVideo => _remote;
public LanCallEndpoint Endpoint => _media.Endpoint;
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
public Task Completion { get; }
public static PodcastCaptureDevices GetDevices()
{
var devices = AndroidPodcastCapture.GetDevices();
return new(devices.Cameras.OrderByDescending(c => c.Name == "Camera avant").ToArray(),
devices.Microphones.Where(m => m.Value == "microphone").ToArray());
}
public AndroidLanCallMedia(string localAddress, int port)
{
_media = new LibVlcLanCallMedia(localAddress, port);
_remote = new AndroidVideoSurface(_media.Receiver, replay: false);
_receiving = ReceiveAsync();
Completion = ObserveAsync();
}
private async Task ReceiveAsync()
{
await _remote.Ready.WaitAsync(TimeSpan.FromSeconds(10), _lifetime.Token);
await _media.StartReceivingAsync(_lifetime.Token);
}
private async Task ObserveAsync()
{
var first = await Task.WhenAny(_media.Completion, _captureEnded.Task, _receiving);
await first;
if (first == _receiving)
{
var finished = await Task.WhenAny(_media.Completion, _captureEnded.Task);
await finished;
}
}
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_capture != null) throw new InvalidOperationException("La capture de cet appel est deja demarree.");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
_capture = await AndroidPodcastCapture.StartCallAsync(camera, microphone,
(fd, token) => _media.StartEncodedSendingAsync(remote, fd, token), _media.StopAsync, linked.Token);
_local.Content = _capture.Preview;
_ = ObserveCaptureAsync(_capture);
}
private async Task ObserveCaptureAsync(IPodcastCapture capture)
{
try
{
await capture.Completion;
if (!_disposed)
_captureEnded.TrySetException(new InvalidOperationException("Capture de l'appel arretee."));
}
catch (Exception error) { _captureEnded.TrySetException(error); }
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
var stop = _capture?.StopAsync();
try
{
if (stop != null) await stop;
}
finally
{
try
{
if (_capture != null) await _capture.DisposeAsync();
}
finally
{
_local.Content = null;
try { await _media.StopAsync(); }
finally
{
_remote.Dispose();
try { await _media.DisposeAsync(); }
finally
{
_lifetime.Dispose();
_captureEnded.TrySetResult();
}
}
}
}
}
}

View file

@ -13,6 +13,7 @@ using Android.OS;
using Android.Views;
using Microsoft.Win32.SafeHandles;
using PostIt.Services;
using Stream = System.IO.Stream;
namespace PostIt.Android.Services;
@ -27,10 +28,13 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
private CameraCaptureSession? _session;
private MediaRecorder? _recorder;
private AndroidCameraFrameRotator? _rotator;
private AndroidCapturePreview? _preview;
public Avalonia.Controls.Control? Preview => _preview;
private Surface? _surface;
private ParcelFileDescriptor? _readPipe;
private ParcelFileDescriptor? _writePipe;
private Task _pump = Task.CompletedTask;
private Func<Task>? _stopOutput;
private Exception? _failure;
private bool _stopped;
private bool _stopRequested;
@ -69,16 +73,26 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
}
public static async Task<IPodcastCapture> StartAsync(string camera, string? microphone, string directory, CancellationToken ct)
=> await StartCoreAsync(camera, microphone,
(stream, token) => new TransportStreamCapture(directory).RunAsync(stream, token), null, null, ct);
internal static Task<IPodcastCapture> StartCallAsync(string camera, string microphone,
Func<int, CancellationToken, Task> output, Func<Task> stopOutput, CancellationToken ct) =>
StartCoreAsync(camera, microphone, null, output, stopOutput, ct);
private static async Task<IPodcastCapture> StartCoreAsync(string camera, string? microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, Func<Task>? stopOutput, CancellationToken ct)
{
await RequestPermissionsAsync(microphone, ct);
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct);
var capture = new AndroidPodcastCapture(service);
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct, pipeOutput != null);
var capture = new AndroidPodcastCapture(service) { _stopOutput = stopOutput };
service.Capture = capture;
try
{
ct.ThrowIfCancellationRequested();
await capture.StartNativeAsync(camera, microphone is not null, directory, ct);
await capture.StartNativeAsync(camera, microphone is not null, output, pipeOutput, ct);
return capture;
}
catch
@ -88,14 +102,18 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
}
}
private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
private async Task StartNativeAsync(string cameraId, bool microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, CancellationToken ct)
{
await _stopGate.WaitAsync(ct);
try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); }
try { await ConfigureNativeAsync(cameraId, microphone, output, pipeOutput, ct); }
finally { _stopGate.Release(); }
}
private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
private async Task ConfigureNativeAsync(string cameraId, bool microphone,
Func<Stream, CancellationToken, Task>? output,
Func<int, CancellationToken, Task>? pipeOutput, CancellationToken ct)
{
var manager = GetManager(_service);
using var characteristics = manager.GetCameraCharacteristics(cameraId);
@ -160,6 +178,7 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
_rotator = await AndroidCameraFrameRotator.CreateAsync(_surface, size.Width, size.Height, rotation, Fail);
_preview = new AndroidCapturePreview(_rotator);
ct.ThrowIfCancellationRequested();
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
var cameraSurface = _rotator.CameraSurface;
@ -176,28 +195,34 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
request.AddTarget(cameraSurface);
using var built = request.Build();
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
if (pipeOutput != null)
await pipeOutput(_readPipe.Fd, ct);
else
{
using (handle)
using (stream)
var consume = output ?? throw new InvalidOperationException("Destination de capture absente.");
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
try
using (handle)
using (stream)
{
await new TransportStreamCapture(directory).RunAsync(stream, CancellationToken.None);
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
_completion.TrySetResult();
try
{
await consume(stream, CancellationToken.None);
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
_completion.TrySetResult();
}
catch (Exception ex)
{
_completion.TrySetException(ex);
// Drain until MediaRecorder stops, so its writer cannot block Stop().
await stream.CopyToAsync(Stream.Null);
}
}
catch (Exception ex)
{
_completion.TrySetException(ex);
// Drain until MediaRecorder stops, so its writer cannot block Stop().
await stream.CopyToAsync(System.IO.Stream.Null);
}
}
});
});
}
await Task.Run(_recorder.Start, ct);
_started = true;
await _rotator.StartAsync();
@ -286,12 +311,48 @@ internal sealed class AndroidPodcastCapture : IPodcastCapture
{
if (_stopped) return;
_stopped = true;
await Task.Run(StopNative);
await _pump;
try
{
if (_preview is not null)
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(_preview.Dispose);
}
finally
{
try { await StopOutputAsync(); }
finally
{
await Task.Run(StopNative);
await _pump;
}
}
_completion.TrySetResult();
}
catch (Exception error)
{
_completion.TrySetException(error);
throw;
}
finally { _stopGate.Release(); }
}
private async Task StopOutputAsync()
{
if (_stopOutput == null || _readPipe == null) return;
try { await _stopOutput(); }
finally
{
// The native sender has released its reader. Drain so the recorder can always stop.
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
var stream = new FileStream(handle, FileAccess.Read);
_pump = Task.Run(async () =>
{
using (handle)
using (stream)
await stream.CopyToAsync(Stream.Null);
});
}
}
private void StopNative()
{
try

View file

@ -1,16 +1,26 @@
using System;
using System.Threading.Tasks;
using Avalonia.Android;
using Avalonia.Controls;
using Avalonia.Platform;
using LibVLCSharp.Shared;
using Android.Content;
using Android.OS;
using Android.Views;
using Android.Widget;
using PostIt.Services;
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
using Button = Android.Widget.Button;
namespace PostIt.Android.Services;
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
internal sealed class AndroidVideoSurface(MediaPlayer player, bool replay) : NativeControlHost, IDisposable
{
private VideoView? _video;
private ReplayLayout? _layout;
private bool _disposed;
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Task Ready => _ready.Task;
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
@ -18,13 +28,23 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
?? throw new InvalidOperationException("L'activite Android est indisponible.");
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
_video = video;
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (!_disposed) _ready.TrySetResult();
}, Avalonia.Threading.DispatcherPriority.Loaded);
if (replay && !_disposed)
{
_layout = new ReplayLayout(activity, video, player);
return new AndroidViewControlHandle(_layout);
}
return new AndroidViewControlHandle(video);
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (control is AndroidViewControlHandle { View: VideoView video })
video.MediaPlayer = null;
_layout?.Stop();
_layout = null;
if (_video is not null) _video.MediaPlayer = null;
_video = null;
base.DestroyNativeControlCore(control);
}
@ -32,6 +52,102 @@ internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHos
public void Dispose()
{
_disposed = true;
_ready.TrySetCanceled();
_layout?.Stop();
if (_video is not null) _video.MediaPlayer = null;
}
private sealed class ReplayLayout : FrameLayout
{
private readonly MediaPlayer _player;
private readonly ReplayControlsState _state = new();
private readonly Handler _handler = new(Looper.MainLooper!);
private readonly Java.Lang.Runnable _tick;
private readonly LinearLayout _bar;
private readonly Button _toggle;
private readonly SeekBar _seek;
private readonly TextView _time;
private bool _stopped;
public ReplayLayout(Context context, VideoView video, MediaPlayer player) : base(context)
{
_player = player;
AddView(video, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent));
_bar = new LinearLayout(context) { Orientation = Orientation.Horizontal };
_bar.SetGravity(GravityFlags.CenterVertical);
_bar.SetPadding(12, 8, 12, 8);
_bar.SetBackgroundColor(global::Android.Graphics.Color.Argb(210, 20, 20, 20));
_toggle = new Button(context) { Text = "Pause", ContentDescription = "Lecture ou pause du replay" };
_seek = new SeekBar(context) { Max = 10000, ContentDescription = "Position dans le replay" };
_time = new TextView(context);
_time.SetTextColor(global::Android.Graphics.Color.White);
_bar.AddView(_toggle);
_bar.AddView(_seek, new LinearLayout.LayoutParams(0, LayoutParams.WrapContent, 1));
_bar.AddView(_time);
AddView(_bar, new FrameLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.WrapContent, GravityFlags.Bottom));
_toggle.Click += Toggle;
_seek.StartTrackingTouch += BeginSeek;
_seek.StopTrackingTouch += EndSeek;
_seek.ProgressChanged += ProgressChanged;
_tick = new Java.Lang.Runnable(Update);
_handler.Post(_tick);
}
public override bool DispatchTouchEvent(MotionEvent? e)
{
_state.Show();
_bar.Visibility = ViewStates.Visible;
return base.DispatchTouchEvent(e);
}
private void Update()
{
if (_stopped) return;
var duration = Math.Max(0, _player.Length);
var position = Math.Max(0, _player.Time);
_bar.Visibility = _state.IsVisible(_player.IsPlaying, _bar.HasFocus) ? ViewStates.Visible : ViewStates.Gone;
_toggle.Text = _player.IsPlaying ? "Pause" : "Lire";
_seek.Enabled = duration > 0 && _player.IsSeekable;
if (!_state.IsInteracting)
_seek.Progress = duration > 0 ? (int)Math.Clamp(position * 10000d / duration, 0, 10000) : 0;
_time.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)(duration * (_seek.Progress / 10000d)) : position)} / {ReplayControlsState.FormatTime(duration)}";
_handler.PostDelayed(_tick, 250);
}
private void Toggle(object? sender, EventArgs e)
{
if (_player.IsPlaying) _player.Pause();
else _player.Play();
_state.Show();
}
private void BeginSeek(object? sender, SeekBar.StartTrackingTouchEventArgs e) => _state.IsInteracting = true;
private void EndSeek(object? sender, SeekBar.StopTrackingTouchEventArgs e)
{
Seek();
_state.IsInteracting = false;
}
private void ProgressChanged(object? sender, SeekBar.ProgressChangedEventArgs e)
{
if (e.FromUser && !_state.IsInteracting) Seek();
}
private void Seek()
{
if (_player.IsSeekable) _player.Time = (long)(_player.Length * (_seek.Progress / 10000d));
_state.Show();
}
public void Stop()
{
if (_stopped) return;
_stopped = true;
_handler.RemoveCallbacks(_tick);
_toggle.Click -= Toggle;
_seek.StartTrackingTouch -= BeginSeek;
_seek.StopTrackingTouch -= EndSeek;
_seek.ProgressChanged -= ProgressChanged;
_tick.Dispose();
_handler.Dispose();
}
}
}

View file

@ -21,14 +21,16 @@ public sealed class PodcastCaptureService : Service
private PowerManager.WakeLock? _wakeLock;
internal AndroidPodcastCapture? Capture { get; set; }
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone, CancellationToken ct)
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone,
CancellationToken ct, bool call = false)
{
if (_pending is not null) throw new InvalidOperationException("Un direct Android est deja en cours.");
var pending = new TaskCompletionSource<PodcastCaptureService>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending = pending;
try
{
var intent = new Intent(activity, typeof(PodcastCaptureService)).PutExtra("microphone", microphone);
var intent = new Intent(activity, typeof(PodcastCaptureService))
.PutExtra("microphone", microphone).PutExtra("call", call);
activity.StartForegroundService(intent);
return await pending.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
}
@ -65,11 +67,13 @@ public sealed class PodcastCaptureService : Service
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
var builder = new NotificationCompat.Builder(this, Channel);
builder.SetSmallIcon(global::Android.Resource.Drawable.IcMenuCamera);
builder.SetContentTitle("Podcast en direct");
builder.SetContentText("Camera active. Toucher pour revenir au direct.");
var call = intent?.GetBooleanExtra("call", false) == true;
builder.SetContentTitle(call ? "Appel audio-video LAN/VPN" : "Podcast en direct");
builder.SetContentText(call ? "Camera et microphone actifs. Toucher pour revenir a l'appel."
: "Camera active. Toucher pour revenir au direct.");
builder.SetContentIntent(open);
builder.SetOngoing(true);
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, "Arreter et publier", stop);
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, call ? "Raccrocher" : "Arreter et publier", stop);
var notification = builder.Build();
var type = (ForegroundService)0;
if (OperatingSystem.IsAndroidVersionAtLeast(30))

View file

@ -4,10 +4,10 @@
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Browser" />

View file

@ -5,10 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -180,6 +180,60 @@ public class AdministrationAccessTests
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task My_profile_loads_and_includes_the_call_privacy_preference_in_the_save_payload(bool initial)
{
await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = initial };
var vm = new UserProfilePageViewModel(api);
await vm.RefreshAsync();
Assert.Equal(initial, vm.HideIpAddressInCalls);
vm.HideIpAddressInCalls = !initial;
Assert.Equal(!initial, vm.BuildProfileUpdateRequest().HideIpAddressInCalls);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
Assert.True(vm.IsProfileLoaded);
}
[AvaloniaFact]
public async Task My_profile_privacy_checkbox_is_two_way_and_disabled_before_loading()
{
await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = true };
var vm = new UserProfilePageViewModel(api);
var page = new UserProfilePage { DataContext = vm };
var window = new Window { Content = page };
try
{
window.Show();
var checkbox = Assert.Single(page.GetVisualDescendants().OfType<CheckBox>(),
control => Equals(control.Content, "Masquer mon adresse IP aux autres participants"));
Assert.False(checkbox.IsEffectivelyEnabled);
await vm.RefreshAsync();
Assert.True(checkbox.IsEffectivelyEnabled);
Assert.True(checkbox.IsChecked);
checkbox.IsChecked = false;
Assert.False(vm.HideIpAddressInCalls);
Assert.Equal(false, vm.BuildProfileUpdateRequest().HideIpAddressInCalls);
vm.IsBusy = true;
Assert.False(checkbox.IsEffectivelyEnabled);
}
finally
{
window.Close();
}
}
[Fact]
public async Task Profile_read_failure_prevents_saving_a_default_call_privacy_preference()
{
await using var api = new ProfileTransport("Performer") { FailProfile = true };
var vm = new UserProfilePageViewModel(api);
await vm.RefreshAsync();
Assert.False(vm.IsProfileLoaded);
Assert.False(vm.SaveProfileCommand.CanExecute(null));
Assert.Contains("Impossible de charger le profil", vm.StatusMessage);
}
internal sealed class ProfileTransport : YavscApiClient
{
private readonly string _tokenPath;
@ -188,6 +242,7 @@ public class AdministrationAccessTests
public Task<Me>? PendingProfile { get; set; }
public bool FailProfile { get; set; }
public bool FailUsage { get; set; }
public bool HideIpAddressInCalls { get; set; }
public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl);
public ProfileTransport(string role)
@ -216,7 +271,7 @@ public class AdministrationAccessTests
if (FailProfile)
throw new HttpRequestException("Profile unavailable");
result = PendingProfile is not null ? await PendingProfile
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role], HideIpAddressInCalls = HideIpAddressInCalls };
}
else if (path.Contains("/resource-usage/"))
{

View file

@ -27,7 +27,7 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
{
foreach (var rotation in new[] { 0, 90, 180, 270 })
await VerifyRotationAsync(rotation);
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\nsource=synthetic");
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\npreview=RGBA,detach,reattach,letterbox\nsource=synthetic");
}
catch (Exception ex)
{
@ -60,6 +60,10 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
recorder.Prepare();
using var encoderSurface = recorder.Surface
?? throw new InvalidOperationException("Encoder surface unavailable.");
using var preview = ImageReader.NewInstance(outputWidth + 160, outputHeight + 80,
(ImageFormatType)Format.Rgba8888, 3)
?? throw new InvalidOperationException("Preview reader unavailable.");
var previewFrames = 0;
Exception? failure = null;
var stopping = 0;
Stage("initialize EGL");
@ -71,6 +75,8 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
File.WriteAllText(Path.Combine(FilesDir.AbsolutePath, "camera-rotation-test-result.txt"), $"FAIL\n{error}");
}))
{
await rotator.SetPreviewAsync(preview.Surface, preview.Width, preview.Height,
error => failure = error);
Stage("start recorder");
recorder.Start();
Task? stop = null;
@ -83,6 +89,10 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
for (var frame = 0; frame < 90; frame++)
{
if (failure is not null) throw failure;
if (frame == 10) await rotator.SetPreviewAsync(null);
if (frame == 15)
await rotator.SetPreviewAsync(preview.Surface, preview.Width, preview.Height,
error => failure = error);
if (frame == 45)
{
Volatile.Write(ref stopping, 1);
@ -107,11 +117,24 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
canvas.Dispose();
}
await Task.Delay(34);
using var image = preview.AcquireLatestImage();
if (image is not null)
{
try
{
VerifyPreview(image, rotation, outputWidth, outputHeight);
previewFrames++;
}
finally { image.Close(); }
}
}
if (failure is not null) throw failure;
Stage("wait for recorder stop");
await (stop ?? throw new InvalidOperationException("Recorder stop was not requested."))
.WaitAsync(TimeSpan.FromSeconds(10));
if (previewFrames < 8)
throw new InvalidOperationException($"Too few preview frames: {previewFrames}.");
await rotator.SetPreviewAsync(null);
}
finally
{
@ -143,5 +166,33 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
}
File.Delete(path);
}
private static void VerifyPreview(Image image, int rotation, int outputWidth, int outputHeight)
{
var plane = image.GetPlanes()![0];
var buffer = plane.Buffer!;
var scale = Math.Min((double)image.Width / outputWidth, (double)image.Height / outputHeight);
var width = (int)(outputWidth * scale);
var height = (int)(outputHeight * scale);
Color[] expected = rotation switch
{
90 => [Color.Blue, Color.Red, Color.Yellow, Color.Green],
180 => [Color.Yellow, Color.Blue, Color.Green, Color.Red],
270 => [Color.Green, Color.Yellow, Color.Red, Color.Blue],
_ => [Color.Red, Color.Green, Color.Blue, Color.Yellow]
};
for (var i = 0; i < expected.Length; i++)
{
var x = (image.Width - width) / 2 + (i % 2 * 2 + 1) * width / 4;
var y = (image.Height - height) / 2 + (i / 2 * 2 + 1) * height / 4;
var offset = y * plane.RowStride + x * plane.PixelStride;
var actual = new Color(buffer.Get(offset) & 255, buffer.Get(offset + 1) & 255, buffer.Get(offset + 2) & 255);
var target = expected[i];
if (Math.Abs(actual.R - target.R) > 5 || Math.Abs(actual.G - target.G) > 5 || Math.Abs(actual.B - target.B) > 5)
throw new InvalidOperationException($"Preview rotation {rotation}, quadrant {i}: {actual}, expected {target}.");
}
if (buffer.Get(0) != 0 || buffer.Get(1) != 0 || buffer.Get(2) != 0)
throw new InvalidOperationException("Preview letterbox is not black.");
}
}
#endif

View file

@ -0,0 +1,158 @@
#if ANDROID
using System;
using Stopwatch = System.Diagnostics.Stopwatch;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.OS;
using Avalonia.Controls;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.Android;
using PostIt.Android.Services;
using PostIt.Services;
using Path = System.IO.Path;
namespace PostIt.Tests;
/// <summary>Debug-only physical camera/SRTP test; no account, API or media recording.</summary>
[Activity(Name = "fr.pschneider.postit.LanCallTestActivity", Exported = true)]
[SupportedOSPlatform("android26.0")]
public sealed class AndroidLanCallTestActivity : Activity
{
protected override async void OnCreate(Bundle? state)
{
base.OnCreate(state);
var result = Path.Combine(FilesDir!.AbsolutePath, "lan-call-test-result.txt");
File.WriteAllText(result, "RUNNING");
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
var outcome = await RunAsync(result, Intent?.GetStringExtra("wrongKey") == "true", timeout.Token);
File.WriteAllText(result, outcome);
}
catch (Exception error) { File.WriteAllText(result, "FAIL\n" + error); }
finally { Finish(); }
}
private async Task<string> RunAsync(string result, bool wrongKey, CancellationToken ct)
{
StartActivity(new Intent(this, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask));
while (!MainActivity.IsForeground || (Avalonia.Application.Current as App)?.View == null)
await Task.Delay(100, ct);
await Task.Delay(1000, ct);
var fixture = Path.Combine(FilesDir!.AbsolutePath, "lan-call-fixture.ts");
if (!File.Exists(fixture)) throw new IOException("Explicit H.264/AAC MPEG-TS fixture required.");
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
await using var peer = new LibVlcLanCallMedia("127.0.0.1", 35102, headless: true);
using var decoded = new DecodedFrames(peer.Receiver);
AndroidLanCallMedia? call = null;
Task? pump = null;
using var sending = CancellationTokenSource.CreateLinkedTokenSource(ct);
var source = File.OpenRead(fixture);
var app = (App)Avalonia.Application.Current!;
var navigation = app.View!.FindControl<NavigationPage>("NavRoot")
?? throw new InvalidOperationException("Main navigation unavailable.");
var attached = false;
try
{
await peer.StartReceivingAsync(ct);
await Dispatcher.UIThread.InvokeAsync(async () =>
{
call = new AndroidLanCallMedia("127.0.0.1", 35100);
var grid = new Grid { RowDefinitions = new RowDefinitions("*,*") };
grid.Children.Add(call.LocalVideo);
Grid.SetRow(call.RemoteVideo, 1);
grid.Children.Add(call.RemoteVideo);
await navigation.PushAsync(new ContentPage { Content = grid });
attached = true;
});
if (call == null) throw new InvalidOperationException("Call UI not created.");
File.WriteAllText(result, "RUNNING\nstage=return source pump");
pump = Task.Run(async () =>
{
var buffer = new byte[1316];
while (await source.ReadAsync(buffer, sending.Token) is var count && count > 0)
{
await pipe.WriteAsync(buffer.AsMemory(0, count), sending.Token);
await Task.Delay(4, sending.Token);
}
}, ct);
File.WriteAllText(result, "RUNNING\nstage=return SRTP sender");
await peer.StartEncodedSendingAsync(call.Endpoint,
pipe.ClientSafePipeHandle.DangerousGetHandle().ToInt32(), ct);
File.WriteAllText(result, "RUNNING\nstage=enumerating cameras");
var devices = AndroidLanCallMedia.GetDevices();
var front = devices.Cameras.FirstOrDefault(c => c.Name == "Camera avant")
?? throw new InvalidOperationException("Front camera required; no rear-camera fallback.");
var endpoint = wrongKey ? peer.Endpoint with { Key = new string('0', 32) } : peer.Endpoint;
File.WriteAllText(result, "RUNNING\nstage=front camera and SRTP");
await Dispatcher.UIThread.InvokeAsync(() => call.StartSendingAsync(endpoint, front.Value, "microphone", ct));
File.WriteAllText(result, "RUNNING\nstage=receiving camera and return media");
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 10 : 25))
{
if (call.Completion.IsCompleted) await call.Completion;
if (peer.Completion.IsCompleted) await peer.Completion;
if (!wrongKey && elapsed.Elapsed >= TimeSpan.FromSeconds(10) &&
decoded.Frames >= 100 && decoded.Samples > 144000 && call.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
if (!call.HasRemoteMedia) throw new IOException("Android did not decode return audio/video.");
if (wrongKey ? decoded.Frames != 0 || decoded.Samples != 0
: decoded.Frames < 100 || decoded.Samples <= 144000)
throw new IOException($"Unexpected camera decode: frames={decoded.Frames}, samples={decoded.Samples}.");
return $"PASS\nfrontCamera=true\nwrongKey={wrongKey}\n" +
$"cameraFrames={decoded.Frames}\ncameraAudioSamples={decoded.Samples}\nelapsedMs={elapsed.ElapsedMilliseconds}\nreturnAudioVideo=true\n" +
"transport=SRTP/UDP loopback\npreview=attached\nsource=camera+microphone,return=explicit fixture\ncleanup=complete";
}
finally
{
sending.Cancel();
File.WriteAllText(result, "RUNNING\nstage=stop return source");
if (pump != null)
{
try { await pump; }
catch (System.OperationCanceledException) when (sending.IsCancellationRequested) { }
}
File.WriteAllText(result, "RUNNING\nstage=dispose call");
if (call != null) await Dispatcher.UIThread.InvokeAsync(async () => await call.DisposeAsync());
File.WriteAllText(result, "RUNNING\nstage=stop return peer");
await peer.StopAsync();
source.Dispose();
File.WriteAllText(result, "RUNNING\nstage=detach test page");
if (attached) await Dispatcher.UIThread.InvokeAsync(async () => await navigation.PopAsync());
}
}
private sealed class DecodedFrames : IDisposable
{
private readonly MediaPlayer _player;
private readonly IntPtr _buffer = Marshal.AllocHGlobal(640 * 640 * 4);
private int _frames;
private long _samples;
public int Frames => Volatile.Read(ref _frames);
public long Samples => Interlocked.Read(ref _samples);
public DecodedFrames(MediaPlayer player)
{
_player = player;
player.SetVideoFormat("RV32", 640, 640, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _buffer);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _samples, count),
null, null, null, null);
}
public void Dispose() { _player.Stop(); Marshal.FreeHGlobal(_buffer); }
}
}
#endif

View file

@ -82,6 +82,29 @@ public class BlogReaderTests
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
}
[Theory]
[InlineData("https://org.example.test/files/paul/clip.mp4", true)]
[InlineData("https://localhost/files/paul/clip.mp4", false)]
public void Video_links_only_open_externally_when_the_source_is_a_real_external_resource(string source, bool expected)
{
var vm = new VideoPageViewModel(new Uri(source), "video/mp4");
Assert.Equal(expected, vm.CanOpenExternal);
Assert.Equal(vm.Source, vm.ExternalUri);
}
[Theory]
[InlineData("https://org.example.test/Podcast", true)]
[InlineData("about:blank", false)]
public void Video_external_action_uses_the_web_resource_instead_of_the_playback_stream(string externalUrl, bool expected)
{
var source = new Uri("https://api.example.test/api/v1/podcasts/123/index.m3u8");
var externalUri = new Uri(externalUrl);
var vm = new VideoPageViewModel(source, "application/vnd.apple.mpegurl", externalUri);
Assert.Equal(source, vm.Source);
Assert.Equal(externalUri, vm.ExternalUri);
Assert.Equal(expected, vm.CanOpenExternal);
}
[AvaloniaFact]
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
{

View file

@ -0,0 +1,60 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class CallSignalingClientTests
{
[Fact]
public async Task All_operations_fail_explicitly_without_a_connected_chat_transport()
{
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
using var client = new CallSignalingClient(connection);
var id = Guid.NewGuid();
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteLanAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), ct));
}
[Fact]
public async Task Disposing_call_subscriptions_does_not_dispose_the_chat_connection()
{
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
var client = new CallSignalingClient(connection);
client.Dispose();
client.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken));
Assert.Equal(HubConnectionState.Disconnected, connection.State);
using var replacement = new CallSignalingClient(connection);
await Assert.ThrowsAsync<InvalidOperationException>(() => replacement.InviteAsync("General", "bob", TestContext.Current.CancellationToken));
}
[Fact]
public void Shared_contracts_round_trip_with_signalr_web_json_naming()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var call = new CallSession(Guid.NewGuid(), "General", "alice", "bob", true,
DateTimeOffset.Parse("2026-10-26T01:00:00Z"));
var ready = new CallAccepted(call, [new(["turns:turn.example.test:5349"], "expires:nonce", "credential")]);
var json = JsonSerializer.Serialize(ready, options);
var read = JsonSerializer.Deserialize<CallAccepted>(json, options);
Assert.NotNull(read);
Assert.Equal(call, read.Session);
Assert.Equal(ready.IceServers[0].Urls, read.IceServers[0].Urls);
Assert.Equal(ready.IceServers[0].Username, read.IceServers[0].Username);
Assert.Equal(ready.IceServers[0].Credential, read.IceServers[0].Credential);
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
SdpMid: "video", SdpMLineIndex: 1);
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp };
Assert.Equal(lan, JsonSerializer.Deserialize<CallSession>(JsonSerializer.Serialize(lan, options), options));
var endpoint = new CallSignal("rtp-endpoint",
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
Assert.Equal(endpoint, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(endpoint, options), options));
}
}

View file

@ -0,0 +1,243 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class LanCallViewModelTests
{
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);
private static LanCallViewModel Configure(Signaling signaling, Func<string, int, ILanCallMedia> create)
{
var vm = new LanCallViewModel(signaling, create)
{
Address = "127.0.0.1", Camera = new("Camera", "/dev/video0"),
Microphone = new("Micro", "default"), AllowAddressDisclosure = true,
Room = "General", Target = "bob"
};
vm.Addresses.Add("127.0.0.1");
return vm;
}
[AvaloniaFact]
public async Task No_capture_starts_before_explicit_acceptance_and_endpoint_exchange()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
Assert.Equal(0, created);
Assert.Empty(signaling.Sent);
await vm.AcceptCommand.ExecuteAsync(null);
await WaitAsync(() => vm.LocalVideo != null);
Assert.Equal(1, created);
Assert.False(media.Sending);
Assert.Single(signaling.Sent);
Assert.Equal("rtp-endpoint", signaling.Sent[0].Kind);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
Assert.Same(media.LocalVideo, vm.LocalVideo);
Assert.Same(media.RemoteVideo, vm.RemoteVideo);
await vm.HangupAsync();
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.LocalVideo);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Caller_waits_for_consent_and_interruption_releases_media_without_reconnecting()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
await vm.InviteCommand.ExecuteAsync(null);
Assert.True(vm.Busy);
Assert.Equal(0, created);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
signaling.Disconnect();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.RemoteVideo);
Assert.Contains("interrompue", vm.Status);
}
[AvaloniaFact]
public async Task Missing_disclosure_consent_or_devices_blocks_the_invitation()
{
var signaling = new Signaling();
await using var vm = Configure(signaling, (_, _) => throw new Exception("Must not capture"));
vm.AllowAddressDisclosure = false;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("Confirmez", vm.Status);
vm.AllowAddressDisclosure = true;
vm.Camera = null;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("camera", vm.Status);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Refusal_never_creates_media_and_late_signals_cannot_restart_capture()
{
var signaling = new Signaling();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return new FakeMedia(); });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
await vm.HangupAsync();
signaling.Ready();
signaling.RemoteEndpoint(signaling.Call.Id);
await Task.Delay(30, TestContext.Current.CancellationToken);
Assert.Equal(0, created);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Platform_devices_and_factory_are_used_and_backgrounding_ends_capture()
{
var previousDevices = Platform.GetLanCallDevices;
var previousFactory = Platform.CreateLanCallMedia;
var signaling = new Signaling();
var media = new FakeMedia();
var creations = 0;
try
{
Platform.GetLanCallDevices = () => new(
[new("Camera avant", "front"), new("Camera arriere", "rear")],
[new("Sans microphone", ""), new("Microphone Android", "microphone")]);
Platform.CreateLanCallMedia = (_, _) => { creations++; return media; };
await using var vm = new LanCallViewModel(signaling);
vm.RefreshDevicesCommand.Execute(null);
Assert.Equal("front", vm.Camera?.Value);
Assert.Equal("microphone", vm.Microphone?.Value);
Assert.Single(vm.Microphones);
Assert.Equal(0, creations);
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
Assert.True(vm.CanConfigure);
vm.RefreshDevicesCommand.Execute(null);
Assert.Equal("front", vm.Camera?.Value);
vm.Address = "127.0.0.1";
if (!vm.Addresses.Contains(vm.Address)) vm.Addresses.Add(vm.Address);
vm.AllowAddressDisclosure = true;
vm.Room = "General";
vm.Target = "bob";
await vm.AcceptCommand.ExecuteAsync(null);
await WaitAsync(() => vm.LocalVideo != null);
Assert.Equal(1, creations);
Assert.False(vm.CanConfigure);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
Platform.SuspendPlayback();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
Platform.ResumePlayback();
await Task.Delay(30, TestContext.Current.CancellationToken);
Assert.Equal(1, creations);
}
finally
{
Platform.GetLanCallDevices = previousDevices;
Platform.CreateLanCallMedia = previousFactory;
}
}
[AvaloniaFact]
public async Task Hangup_cancels_pending_native_start_without_waiting_for_startup_timeout()
{
var signaling = new Signaling();
var media = new FakeMedia { DelayStartup = true };
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
await vm.HangupAsync().WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Media_failure_ends_the_call_and_disposes_capture()
{
var signaling = new Signaling();
var media = new FakeMedia();
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
media.Fail();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Contains("test media failure", vm.Status);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
private static async Task WaitAsync(Func<bool> condition)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (!condition()) await Task.Delay(10, timeout.Token);
}
private sealed class FakeMedia : ILanCallMedia
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo { get; } = new Border();
public Control RemoteVideo { get; } = new Border();
public LanCallEndpoint Endpoint { get; } = new("127.0.0.1", 5004, new string('a', 32), new string('b', 28));
public Task Completion => _completion.Task;
public bool HasRemoteMedia => false;
public bool Sending { get; private set; }
public bool Disposed { get; private set; }
public bool DelayStartup { get; init; }
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
Sending = true;
if (DelayStartup) await Task.Delay(Timeout.Infinite, ct);
}
public void Fail() => _completion.TrySetException(new IOException("test media failure"));
public ValueTask DisposeAsync() { Disposed = true; _completion.TrySetResult(); return ValueTask.CompletedTask; }
}
private sealed class Signaling : ILanCallSignaling
{
public event Action<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSession Call { get; } = Session();
public List<CallSignal> Sent { get; } = [];
public List<Guid> EndedCalls { get; } = [];
public int Invites { get; private set; }
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{ Invites++; return Task.FromResult(Call); }
public Task AcceptAsync(Guid id, CancellationToken ct = default) { Ready(); return Task.CompletedTask; }
public Task EndAsync(Guid id, CancellationToken ct = default)
{ EndedCalls.Add(id); Ended?.Invoke(new(id, "hangup")); return Task.CompletedTask; }
public Task SendAsync(Guid id, CallSignal signal, CancellationToken ct = default)
{ Sent.Add(signal); return Task.CompletedTask; }
public void Invite(LanCallViewModel _, CallSession call) => Invitation?.Invoke(call);
public void Ready() => Accepted?.Invoke(new(Call, []));
public void RemoteEndpoint(Guid id) => Signal?.Invoke(id, new("rtp-endpoint",
Endpoint: new("127.0.0.1", 5006, new string('c', 32), new string('d', 28))));
public void Disconnect() => Interrupted?.Invoke();
}
}

View file

@ -0,0 +1,190 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Tests;
public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
{
public static bool HasFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
public static bool HasTsFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_TS"));
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public async Task Ports_are_released_and_credentials_regenerated_after_disposal()
{
var port = FreePort();
var first = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
var endpoint = first.Endpoint;
await first.StartReceivingAsync(TestContext.Current.CancellationToken);
await first.DisposeAsync();
await first.DisposeAsync();
await using var next = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
Assert.NotEqual(endpoint.Key, next.Endpoint.Key);
Assert.NotEqual(endpoint.Salt, next.Endpoint.Salt);
await next.StartReceivingAsync(TestContext.Current.CancellationToken);
}
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public void Occupied_rtcp_port_is_rejected_before_capture_starts()
{
var port = FreePort();
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia("127.0.0.1", port, headless: true));
Assert.Equal(SocketError.AddressAlreadyInUse, error.SocketErrorCode);
}
[Fact(Skip = "Set POSTIT_TEST_TS to an H.264/AAC MPEG-TS file.", SkipUnless = nameof(HasTsFixture))]
public async Task Already_encoded_capture_is_sent_over_srtp_without_transcoding()
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
await using var sender = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var receiver = new LibVlcLanCallMedia("127.0.0.1", FreePort(firstPort), headless: true);
using var decoded = new DecodedVideo(receiver.Receiver);
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
await using var source = File.OpenRead(Environment.GetEnvironmentVariable("POSTIT_TEST_TS")!);
await receiver.StartReceivingAsync(ct);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct);
var pump = Task.Run(async () =>
{
var buffer = new byte[1316];
while (await source.ReadAsync(buffer, lifetime.Token) is var count && count > 0)
{
await pipe.WriteAsync(buffer.AsMemory(0, count), lifetime.Token);
await Task.Delay(4, lifetime.Token);
}
}, ct);
try
{
await sender.StartEncodedSendingAsync(receiver.Endpoint,
pipe.ClientSafePipeHandle.DangerousGetHandle().ToInt32(), ct);
var elapsed = Stopwatch.StartNew();
while (!HasMedia(receiver, decoded) && elapsed.Elapsed < TimeSpan.FromSeconds(20))
{
if (sender.Completion.IsCompleted) await sender.Completion;
if (receiver.Completion.IsCompleted) await receiver.Completion;
await Task.Delay(100, ct);
}
Assert.True(HasMedia(receiver, decoded));
}
finally
{
lifetime.Cancel();
try { await pump; }
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
await sender.StopAsync();
}
}
[Theory(Skip = "Set POSTIT_TEST_MEDIA to an audio/video file; requires Linux LibVLC SRTP and H.264/AAC encoders.",
SkipUnless = nameof(HasFixture))]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(bool wrongKey, bool preview)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
await using var first = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var second = new LibVlcLanCallMedia("127.0.0.1", secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct);
await second.StartReceivingAsync(ct);
await Task.Delay(300, ct);
Media Source(LibVLC vlc)
{
var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
media.AddOption(":input-repeat=5");
return media;
}
await first.StartSendingAsync(wrongKey ? second.Endpoint with { Key = new string('0', 32) } : second.Endpoint,
Source, preview, ct);
await second.StartSendingAsync(first.Endpoint, Source, preview, ct);
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 8 : 20))
{
if (first.Completion.IsCompleted) await first.Completion;
if (second.Completion.IsCompleted) await second.Completion;
if (!wrongKey && HasMedia(first, firstVideo) && HasMedia(second, secondVideo) &&
first.HasRemoteMedia && second.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
var a = first.Statistics;
var b = second.Statistics;
output.WriteLine($"A: audioSamples={firstVideo.AudioSamples}, rendered={firstVideo.Frames}; " +
$"B: audioSamples={secondVideo.AudioSamples}, rendered={secondVideo.Frames}; elapsed={elapsed.ElapsedMilliseconds}ms");
Assert.True(HasMedia(first, firstVideo), "First peer must decode received audio and video.");
Assert.True(first.HasRemoteMedia, "The UI must detect initial audio/video reception.");
if (wrongKey)
{
Assert.Equal(0, b.DecodedVideo);
Assert.Equal(0, b.DecodedAudio);
Assert.Equal(0, secondVideo.Frames);
Assert.Equal(0, secondVideo.AudioSamples);
Assert.False(second.HasRemoteMedia);
}
else
{
Assert.True(HasMedia(second, secondVideo), "Second peer must decode received audio and video.");
Assert.True(second.HasRemoteMedia);
}
}
private static bool HasMedia(LibVlcLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
private static int FreePort(int except = 0)
{
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
using var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try
{
socket.Bind(new IPEndPoint(IPAddress.Loopback, port));
rtcp.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
return port;
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }
}
throw new IOException("No free RTP port available.");
}
private sealed class DecodedVideo : IDisposable
{
private readonly IntPtr _pixels = Marshal.AllocHGlobal(640 * 480 * 4);
private int _frames;
private long _audioSamples;
public int Frames => Volatile.Read(ref _frames);
public long AudioSamples => Interlocked.Read(ref _audioSamples);
private readonly MediaPlayer _player;
public DecodedVideo(MediaPlayer player)
{
_player = player;
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _audioSamples, count),
null, null, null, null);
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _pixels);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
}
public void Dispose()
{
_player.Stop();
Marshal.FreeHGlobal(_pixels);
}
}
}

View file

@ -0,0 +1,72 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PerformerDonationSettingsTests
{
[Theory]
[InlineData("performer@payments.example", "performer@payments.example")]
[InlineData(" performer@payments.example ", "performer@payments.example")]
[InlineData("", "")]
public void Performer_editor_includes_the_paypal_recipient_in_the_save_payload(string value, string expected)
{
var vm = new PerformerConfigurationPageViewModel { DonationPayPalEmail = value };
Assert.Equal(expected, vm.BuildPerformerProfileSettings().DonationPayPalEmail);
}
[AvaloniaFact]
public async Task Performer_editor_loads_displays_edits_and_saves_the_private_recipient()
{
var tokenPath = Path.Combine(Path.GetTempPath(), "postit-donation-" + Guid.NewGuid().ToString("N") + ".json");
try
{
await using var api = new PerformerApi(tokenPath);
var vm = new PerformerConfigurationPageViewModel(api, new ActivityApiClient(api, api.Settings.ApiUrl));
await vm.RefreshAsync();
Assert.Equal("initial@payments.example", vm.DonationPayPalEmail);
var page = new PerformerConfigurationPage { DataContext = vm };
var input = page.FindControl<TextBox>("DonationPayPalEmailInput")!;
Assert.Equal("initial@payments.example", input.Text);
input.Text = "updated@payments.example";
Assert.Equal("updated@payments.example", vm.DonationPayPalEmail);
await vm.SaveAsync();
Assert.Equal("updated@payments.example", api.Saved?.DonationPayPalEmail);
input.Text = "";
await vm.SaveAsync();
Assert.Equal("", api.Saved?.DonationPayPalEmail);
}
finally
{
if (File.Exists(tokenPath)) File.Delete(tokenPath);
}
}
private sealed class PerformerApi(string tokenPath) : YavscApiClient(new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://org.example.test", ClientId = "postit", Scopes = ["openid", "api"]
}
}, new TokenStore(tokenPath))
{
public PerformerProfileSettings? Saved { get; private set; }
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (typeof(T) == typeof(List<ActivityInfo>))
return Task.FromResult((T)(object)new List<ActivityInfo>());
Assert.EndsWith("/account/performer-profile", path);
if (method == HttpMethod.Put) Saved = Assert.IsType<PerformerProfileSettings>(body);
return Task.FromResult((T)(object)new PerformerProfileSettings
{
PerformerId = "author", DonationPayPalEmail = "initial@payments.example"
});
}
}
}

View file

@ -1,7 +1,10 @@
using System.Net;
using Avalonia.Headless.XUnit;
using Avalonia.Headless;
using Avalonia.Controls;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
@ -9,6 +12,70 @@ namespace PostIt.Tests;
public sealed class PodcastBroadcasterTests
{
[Fact]
public Task Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-preview-{Guid.NewGuid():N}.json");
var devices = Platform.GetPodcastCaptureDevices;
var permissions = Platform.RequestPodcastPermissionsAsync;
var factory = Platform.CreatePodcastCaptureAsync;
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store);
await using var transport = new Transport();
FakeCapture? capture = null;
PodcastsPageViewModel? vm = null;
try
{
var preview = new Border();
var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Platform.GetPodcastCaptureDevices = () => new([new("Camera", "camera")], [new("None", "")]);
Platform.RequestPodcastPermissionsAsync = (_, _) => Task.CompletedTask;
Platform.CreatePodcastCaptureAsync = (_, _, directory, _) =>
{
capture = new FakeCapture(directory, preview)
{
OnDispose = () =>
{
Assert.Null(vm!.CapturePreview);
disposed.TrySetResult();
}
};
return Task.FromResult<IPodcastCapture>(capture);
};
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
{
Title = "Preview"
};
vm.DetectDevicesCommand.Execute(null);
await vm.StartCommand.ExecuteAsync(null);
Assert.Same(preview, vm.CapturePreview);
Assert.True(vm.IsBroadcasting);
var page = new PodcastsPage { DataContext = vm };
var host = page.FindControl<ContentControl>("CapturePreviewHost");
Assert.NotNull(host);
Assert.Same(preview, host.Content);
await vm.DeactivateAsync();
await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Null(vm.CapturePreview);
Assert.Null(host.Content);
Assert.True(capture!.Stopped);
return true;
}
finally
{
if (vm?.IsBroadcasting == true) await vm.StopAsync();
Platform.GetPodcastCaptureDevices = devices;
Platform.RequestPodcastPermissionsAsync = permissions;
Platform.CreatePodcastCaptureAsync = factory;
File.Delete(tokenPath);
File.Delete(tokenPath + ".settings");
}
}, TestContext.Current.CancellationToken);
[AvaloniaFact]
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
{
@ -18,7 +85,7 @@ public sealed class PodcastBroadcasterTests
var factory = Platform.CreatePodcastCaptureAsync;
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(new Settings(), store);
await using var api = new YavscApiClient(new Settings(tokenPath + ".settings"), store);
await using var transport = new Transport();
try
{
@ -53,9 +120,26 @@ public sealed class PodcastBroadcasterTests
Platform.RequestPodcastPermissionsAsync = permissions;
Platform.CreatePodcastCaptureAsync = factory;
File.Delete(tokenPath);
File.Delete(tokenPath + ".settings");
}
}
[Fact]
public async Task Prepared_broadcast_starts_the_existing_emission_instead_of_creating_another()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Prepared",
(directory, _) => Task.FromResult<IPodcastCapture>(capture = new FakeCapture(directory)),
TestContext.Current.CancellationToken, transport.PodcastId);
Assert.Equal(transport.PodcastId, broadcaster.Podcast.Id);
Assert.Equal(transport.PodcastId, transport.StartedId);
Assert.Equal(0, transport.CreateCount);
await capture!.StopAsync();
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
[Fact]
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
{
@ -120,9 +204,12 @@ public sealed class PodcastBroadcasterTests
public string Directory { get; }
public bool Stopped { get; private set; }
public bool Disposed { get; private set; }
public Control? Preview { get; }
public Action? OnDispose { get; init; }
public Task Completion => _completion.Task;
public FakeCapture(string directory)
public FakeCapture(string directory, Control? preview = null)
{
Preview = preview;
Directory = directory;
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
@ -136,7 +223,7 @@ public sealed class PodcastBroadcasterTests
_completion.TrySetResult();
return Task.CompletedTask;
}
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
public ValueTask DisposeAsync() { Disposed = true; OnDispose?.Invoke(); return ValueTask.CompletedTask; }
}
private sealed class Transport : IYavscApiClient
@ -148,10 +235,14 @@ public sealed class PodcastBroadcasterTests
public List<string> Events { get; } = [];
public bool FailUpload { get; init; }
public int CreateCount { get; private set; }
public Guid PodcastId => _podcast.Id;
public Guid? StartedId { get; private set; }
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
else if (path.EndsWith("/start", StringComparison.Ordinal))
StartedId = Guid.Parse(new Uri(path).Segments[^2].TrimEnd('/'));
else CreateCount++;
return Task.FromResult((T)(object)_podcast);
}

View file

@ -1,5 +1,8 @@
using System.Net;
using System.Net.Http;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
@ -7,28 +10,47 @@ using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastNativeCaptureTests
public sealed class PodcastNativeCaptureTests(ITestOutputHelper output)
{
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder()
[Theory(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
[InlineData(false)]
[InlineData(true)]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder(bool preview)
{
var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}");
Directory.CreateDirectory(root);
var pixels = preview ? Marshal.AllocHGlobal(640 * 480 * 4) : IntPtr.Zero;
try
{
Core.Initialize();
using var vlc = new LibVLC();
using var vlc = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib",
"--avcodec-hw=none", "--avcodec-threads=2");
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
using var player = new MediaPlayer(vlc);
PodcastBroadcaster.ConfigureOutput(media, root);
var displayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
if (preview)
{
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, pixels);
return IntPtr.Zero;
}, null, (_, _) => displayed.TrySetResult());
}
PodcastBroadcaster.ConfigureOutput(media, root, preview);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed."));
Assert.True(player.Play(media));
output.WriteLine($"Encoding started (preview={preview}).");
if (preview)
await displayed.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
output.WriteLine("End reached; stopping player.");
player.Stop();
output.WriteLine("Player stopped; checking segments.");
var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(playlist);
Assert.True(segments.Count >= 2, playlist);
@ -41,8 +63,34 @@ public sealed class PodcastNativeCaptureTests
Assert.InRange(segment.DurationSeconds, 0.001, 15);
for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]);
}
var start = new ProcessStartInfo("ffprobe") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var argument in new[] { "-v", "error", "-show_streams", "-of", "json", Path.Combine(root, segments[0].FileName) })
start.ArgumentList.Add(argument);
using var probe = Process.Start(start) ?? throw new IOException("Cannot start ffprobe.");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
var stdout = probe.StandardOutput.ReadToEndAsync(timeout.Token);
var stderr = probe.StandardError.ReadToEndAsync(timeout.Token);
try
{
await probe.WaitForExitAsync(timeout.Token);
Assert.True(probe.ExitCode == 0, await stderr);
using var json = JsonDocument.Parse(await stdout);
var streams = json.RootElement.GetProperty("streams").EnumerateArray().ToArray();
Assert.Contains(streams, track => track.GetProperty("codec_name").GetString() == "h264");
Assert.Contains(streams, track => track.GetProperty("codec_name").GetString() == "aac");
}
finally
{
if (!probe.HasExited) probe.Kill(entireProcessTree: true);
await probe.WaitForExitAsync(CancellationToken.None);
}
}
finally
{
if (pixels != IntPtr.Zero) Marshal.FreeHGlobal(pixels);
Directory.Delete(root, recursive: true);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]

View file

@ -0,0 +1,79 @@
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastPreferencesTests
{
[AvaloniaFact]
public async Task Detection_selection_and_title_survive_settings_reload_without_detecting_again()
{
var path = Path.Combine(Path.GetTempPath(), $"postit-preferences-{Guid.NewGuid():N}.json");
var previous = Platform.GetPodcastCaptureDevices;
try
{
Platform.GetPodcastCaptureDevices = () => new(
[new("Front", "front"), new("Rear", "rear")],
[new("None", ""), new("USB", "usb")]);
var settings = new Settings(path);
await using var api = new YavscApiClient(settings, new TokenStore(path + ".tokens"));
var session = new SessionStatusViewModel();
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => settings.ApiUrl), api, session);
vm.DetectDevicesCommand.Execute(null);
vm.SelectedCamera = vm.Cameras[1];
vm.SelectedMicrophone = vm.Microphones[0];
vm.Title = "Mon emission habituelle";
vm.DetectDevicesCommand.Execute(null);
Assert.Equal("rear", vm.SelectedCamera?.Value);
Assert.Equal("", vm.SelectedMicrophone?.Value);
var restored = new Settings(path);
restored.Authentication.Authority = "";
restored.Load();
Assert.False(restored.IsDirty);
Assert.Equal("Mon emission habituelle", restored.PodcastDefaultTitle);
Assert.Equal("rear", restored.PodcastCameraId);
Assert.Equal("", restored.PodcastMicrophoneId);
Platform.GetPodcastCaptureDevices = () => throw new InvalidOperationException("Must use saved devices.");
await using var otherApi = new YavscApiClient(restored, new TokenStore(path + ".tokens"));
var other = new PodcastsPageViewModel(new PodcastApiClient(otherApi, () => restored.ApiUrl), otherApi, session);
Assert.Equal(2, other.Cameras.Count);
Assert.Equal(2, other.Microphones.Count);
Assert.Equal("rear", other.SelectedCamera?.Value);
Assert.Equal("", other.SelectedMicrophone?.Value);
Assert.Equal(vm.Title, other.Title);
Platform.GetPodcastCaptureDevices = () => new([], []);
other.DetectDevicesCommand.Execute(null);
Assert.Null(other.SelectedCamera);
Assert.Null(other.SelectedMicrophone);
Assert.Contains("Aucune camera", other.Status);
}
finally
{
Platform.GetPodcastCaptureDevices = previous;
File.Delete(path);
File.Delete(path + ".tokens");
}
}
[AvaloniaFact]
public async Task Failed_save_is_reported_without_losing_the_title()
{
var path = Path.Combine(Path.GetTempPath(), $"postit-preferences-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
try
{
var settings = new Settings(path);
await using var api = new YavscApiClient(settings, new TokenStore(path + ".tokens"));
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => settings.ApiUrl), api, new());
vm.Title = "Titre conserve";
Assert.Equal("Titre conserve", vm.Title);
Assert.Contains("sauvegarde impossible", vm.Status);
Assert.True(settings.IsDirty);
}
finally { Directory.Delete(path); File.Delete(path + ".tokens"); }
}
}

View file

@ -0,0 +1,199 @@
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using Avalonia.Controls;
using Avalonia.Headless;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastReplayTests
{
private static readonly Guid Id = Guid.NewGuid();
private readonly CancellationToken _ct = TestContext.Current.CancellationToken;
public static bool HasNativeFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY"));
[Fact(Skip = "Set POSTIT_TEST_REPLAY to a local MP4 (8+ seconds) and install LibVLC.", SkipUnless = nameof(HasNativeFixture))]
public async Task LibVLC_decodes_authenticated_MP4_ranges_and_seeks_in_both_directions()
{
using var transport = new RangeTransport
{
Bytes = await File.ReadAllBytesAsync(Environment.GetEnvironmentVariable("POSTIT_TEST_REPLAY")!, _ct)
};
var api = new PodcastApiClient(transport, () => "https://api.example/");
Core.Initialize();
using var vlc = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
using var media = new Media(vlc, input);
using var player = new MediaPlayer(vlc);
string? failure = null;
input.Failed += message => failure = message;
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new IOException("LibVLC MP4 decoding failed."));
Assert.True(player.Play(media));
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
while (player.Length <= 0 || !player.IsSeekable || player.Time <= 0)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Length, 8000, 30000);
var forward = player.Length / 2;
player.Time = forward;
while (player.Time < forward - 300)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, forward - 300, forward + 1000);
player.Time = 1000;
while (player.Time > 2500)
await Task.Delay(20, timeout.Token);
Assert.InRange(player.Time, 500, 2500);
await ended.Task.WaitAsync(timeout.Token);
Assert.Null(failure);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Video && t.Codec == 0x34363268);
Assert.Contains(media.Tracks, t => t.TrackType == TrackType.Audio && t.Codec == 0x6134706d);
}
finally
{
input.Cancel();
player.Stop();
}
}
[Fact]
public Task Desktop_overlay_has_real_position_pause_and_duration_controls()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastReplayTests).Assembly).Dispatch(() =>
{
var overlay = new PostIt.Controls.ReplayControls();
var window = new Window { Content = overlay, Width = 640, Height = 360 };
try
{
window.Show();
Assert.NotNull(overlay.FindControl<Slider>("PositionSlider"));
Assert.NotNull(overlay.FindControl<Button>("ToggleButton"));
Assert.Equal("0:00 / 0:00", overlay.FindControl<TextBlock>("TimeLabel")?.Text);
}
finally { window.Close(); }
}, _ct);
[Fact]
public async Task Ranges_use_authenticated_factory_with_bounded_exact_content_and_no_token_in_URL()
{
await using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
Assert.Equal(transport.Bytes.Skip(12).Take(3), await api.DownloadReplayRangeAsync(Id, 12, 3, transport.Bytes.Length, _ct));
Assert.Equal("bytes=12-14", transport.Range);
Assert.Equal($"https://api.example/api/v1/podcasts/{Id}/replay.mp4", transport.Url);
Assert.Equal(transport.Bytes.Skip(transport.Bytes.Length - 2),
await api.DownloadReplayRangeAsync(Id, transport.Bytes.Length - 2, 256 * 1024, transport.Bytes.Length, _ct));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
api.DownloadReplayRangeAsync(Id, 0, 256 * 1024 + 1, transport.Bytes.Length, _ct));
}
[Theory]
[InlineData("full")]
[InlineData("range")]
[InlineData("size")]
[InlineData("extra")]
public async Task Untrusted_or_unbounded_responses_fail_instead_of_playing_wrong_bytes(string failure)
{
await using var transport = new RangeTransport { Failure = failure };
var api = new PodcastApiClient(transport, () => "https://api.example/");
await Assert.ThrowsAsync<InvalidDataException>(() => api.DownloadReplayRangeAsync(Id, 0, 8, transport.Bytes.Length, _ct));
}
[Fact]
public void Native_input_supports_backward_and_forward_seek_EOF_and_cancellation()
{
using var transport = new RangeTransport();
var api = new PodcastApiClient(transport, () => "https://api.example/");
using var input = new PodcastReplayMediaInput(api, Id, transport.Bytes.Length);
Assert.True(input.CanSeek);
var buffer = Marshal.AllocHGlobal(16);
try
{
Assert.True(input.Open(out var size));
Assert.Equal((ulong)transport.Bytes.Length, size);
Assert.True(input.CanSeek);
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(0, Marshal.ReadByte(buffer));
Assert.True(input.Seek(300000));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(transport.Bytes[300000], Marshal.ReadByte(buffer));
Assert.True(input.Seek(2));
Assert.Equal(16, input.Read(buffer, 16));
Assert.Equal(2, Marshal.ReadByte(buffer));
Assert.True(input.Seek(size - 1));
Assert.Equal(1, input.Read(buffer, 16));
Assert.Equal(0, input.Read(buffer, 16));
Assert.False(input.Seek(size + 1));
string? error = null;
input.Failed += message => error = message;
input.Cancel();
Assert.Equal(-1, input.Read(buffer, 16));
Assert.Null(error);
}
finally { Marshal.FreeHGlobal(buffer); }
}
[Fact]
public void Controls_hide_at_three_seconds_but_not_during_drag_pause_or_keyboard_focus()
{
var clock = new Clock();
var controls = new ReplayControlsState(clock);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(2999);
Assert.True(controls.IsVisible(true, false));
clock.Now += TimeSpan.FromMilliseconds(1);
Assert.False(controls.IsVisible(true, false));
Assert.True(controls.IsVisible(false, false));
Assert.True(controls.IsVisible(true, true));
controls.IsInteracting = true;
Assert.True(controls.IsVisible(true, false));
controls.IsInteracting = false;
controls.Show();
Assert.True(controls.IsVisible(true, false));
Assert.Equal("0:00", ReplayControlsState.FormatTime(-1));
Assert.Equal("1:02", ReplayControlsState.FormatTime(62000));
Assert.Equal("1:01:02", ReplayControlsState.FormatTime(3662000));
}
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class RangeTransport : IYavscApiClient, IDisposable
{
public byte[] Bytes { get; init; } = Enumerable.Range(0, 600000).Select(i => (byte)i).ToArray();
public string? Failure { get; init; }
public string? Range { get; private set; }
public string? Url { get; private set; }
public HttpClient Http { get; } = new();
public Task<HttpResponseMessage> SendAuthenticatedAsync(Func<HttpRequestMessage> factory, CancellationToken ct = default)
{
using var request = factory();
Range = request.Headers.Range?.ToString();
Url = request.RequestUri!.AbsoluteUri;
var item = request.Headers.Range!.Ranges.Single();
var from = item.From!.Value;
var to = item.To!.Value;
var response = new HttpResponseMessage(Failure == "full" ? HttpStatusCode.OK : HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(Bytes[(int)from..(int)(to + 1 + (Failure == "extra" ? 1 : 0))])
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(
Failure == "range" ? from + 1 : from, to, Failure == "size" ? Bytes.Length + 1 : Bytes.Length);
return Task.FromResult(response);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> factory, CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
public void Dispose() => Http.Dispose();
}
}

View file

@ -0,0 +1,93 @@
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastScheduleTests
{
[AvaloniaFact]
public async Task Preparation_and_editing_send_schedule_and_optional_announcement_without_capture()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-plan-{Guid.NewGuid():N}.json");
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
try
{
await using var api = new ScheduleApi(store, tokenPath + ".settings");
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => "https://api.example/"), api, session)
{
Title = "Concert", ScheduledDate = DateTimeOffset.Now.AddDays(7),
ScheduledTime = new TimeSpan(14, 15, 0), PlannedDurationMinutes = 90
};
Assert.True(vm.PrepareCommand.CanExecute(null));
await vm.PrepareCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Post, api.SavedMethod);
Assert.EndsWith("/podcasts/planned", api.SavedPath);
Assert.Equal(90, api.Saved?.PlannedDurationMinutes);
Assert.Null(api.Saved?.PublicAnnouncement);
Assert.Equal(14, api.Saved?.ScheduledStartAt.Hour);
Assert.Equal(PodcastState.Planned, vm.SelectedPodcast?.State);
Assert.False(vm.FinishCommand.CanExecute(null));
Assert.True(vm.DeleteCommand.CanExecute(null));
Assert.True(vm.UpdatePlanCommand.CanExecute(null));
vm.Title = "Nouveau titre";
vm.PublishAnnouncement = true;
vm.Announcement = "Venez au concert";
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.SavedMethod);
Assert.EndsWith($"/podcasts/{api.Plan.Id}/planned", api.SavedPath);
Assert.Equal("Venez au concert", api.Saved?.PublicAnnouncement);
Assert.Equal("Nouveau titre", vm.SelectedPodcast?.Title);
vm.PublishAnnouncement = false;
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Null(api.Saved?.PublicAnnouncement);
vm.PlannedDurationMinutes = 0;
api.Saved = null;
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Null(api.Saved);
Assert.Contains("Preparation impossible", vm.Status);
Assert.False(vm.IsBroadcasting);
vm.SelectedPodcast = api.Plan with { Title = "Titre programme distinct" };
Assert.Equal("Titre programme distinct", vm.Title);
Assert.Equal("Nouveau titre", api.Settings.PodcastDefaultTitle);
vm.NewPlanCommand.Execute(null);
Assert.Equal("Nouveau titre", vm.Title);
}
finally { File.Delete(tokenPath); File.Delete(tokenPath + ".settings"); }
}
private sealed class ScheduleApi(TokenStore store, string settingsPath) : YavscApiClient(new Settings(settingsPath), store)
{
public PodcastResponse Plan { get; private set; } = new(Guid.NewGuid(), "Concert", "owner", "Owner",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Planned, 0, 0, 0);
public PreparePodcastRequest? Saved { get; set; }
public string? SavedPath { get; private set; }
public HttpMethod? SavedMethod { get; private set; }
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
object response;
if (path.EndsWith("/account/me", StringComparison.Ordinal)) response = new Me { UserId = "owner" };
else if (method == HttpMethod.Get) response = new List<PodcastResponse> { Plan };
else
{
Saved = Assert.IsType<PreparePodcastRequest>(body);
SavedPath = path;
SavedMethod = method;
Plan = Plan with
{
Title = Saved.Title, ScheduledStartAt = Saved.ScheduledStartAt,
PlannedDurationMinutes = Saved.PlannedDurationMinutes, PublicAnnouncement = Saved.PublicAnnouncement
};
response = Plan;
}
return Task.FromResult((T)response);
}
}
}

View file

@ -6,10 +6,10 @@
<IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />

View file

@ -17,6 +17,38 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Authenticated_streaming_refreshes_tokens_and_recreates_range_requests(bool expired)
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(forceFirstRequest: !expired);
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
if (expired) ExpireCachedAccessToken(tokensPath);
await using var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var requests = new List<HttpRequestMessage>();
using var response = await client.SendAuthenticatedAsync(() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, apiServer.BaseUrl + "/posts");
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(10, 20);
requests.Add(request);
return request;
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(expired ? 1 : 2, requests.Count);
Assert.All(requests, request => Assert.Equal("bytes=10-20", request.Headers.Range?.ToString()));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEmpty(apiServer.SeenBearers);
}
finally { File.Delete(tokensPath); }
}
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{

View file

@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Controls.ReplayControls"
Background="Transparent">
<Border x:Name="ControlBar" Background="#CC151515" Padding="12"
VerticalAlignment="Bottom">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<Button x:Name="ToggleButton" Content="Pause" Click="Toggle_Click"
AutomationProperties.Name="Lecture ou pause du replay" />
<Slider x:Name="PositionSlider" Grid.Column="1" Minimum="0" Maximum="1"
ValueChanged="Position_Changed" VerticalAlignment="Center"
AutomationProperties.Name="Position dans le replay" />
<TextBlock x:Name="TimeLabel" Grid.Column="2" Foreground="White"
VerticalAlignment="Center" Text="0:00 / 0:00" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,92 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Controls;
public partial class ReplayControls : UserControl
{
private readonly ReplayControlsState _state = new();
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(250) };
private MediaPlayer? _player;
private bool _updating;
public ReplayControls()
{
InitializeComponent();
_timer.Tick += Update;
PointerMoved += (_, _) => _state.Show();
PointerPressed += (_, _) => _state.Show();
KeyDown += (_, _) => _state.Show();
PositionSlider.AddHandler(PointerPressedEvent, BeginSeek, RoutingStrategies.Tunnel);
PositionSlider.AddHandler(PointerReleasedEvent, EndSeek, RoutingStrategies.Bubble, handledEventsToo: true);
PositionSlider.PointerCaptureLost += (_, _) =>
{
if (_state.IsInteracting) CommitSeek();
};
}
public ReplayControls(MediaPlayer player) : this() => _player = player;
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_state.Show();
_timer.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
base.OnDetachedFromVisualTree(e);
}
private void Update(object? sender, EventArgs e)
{
if (_player is not { } player) return;
var duration = Math.Max(0, player.Length);
var position = Math.Max(0, player.Time);
ControlBar.IsVisible = _state.IsVisible(player.IsPlaying, ControlBar.IsKeyboardFocusWithin);
ToggleButton.Content = player.IsPlaying ? "Pause" : "Lire";
PositionSlider.IsEnabled = duration > 0 && player.IsSeekable;
_updating = true;
PositionSlider.Maximum = Math.Max(1, duration);
if (!_state.IsInteracting) PositionSlider.Value = Math.Min(position, duration);
_updating = false;
TimeLabel.Text = $"{ReplayControlsState.FormatTime(_state.IsInteracting ? (long)PositionSlider.Value : position)} / {ReplayControlsState.FormatTime(duration)}";
}
private void BeginSeek(object? sender, PointerPressedEventArgs e)
{
_state.IsInteracting = true;
_state.Show();
}
private void EndSeek(object? sender, PointerReleasedEventArgs e) => CommitSeek();
private void CommitSeek()
{
if (_player is { IsSeekable: true } player) player.Time = (long)PositionSlider.Value;
_state.IsInteracting = false;
_state.Show();
}
private void Position_Changed(object? sender, RangeBaseValueChangedEventArgs e)
{
if (!_updating && !_state.IsInteracting) CommitSeek();
}
private void Toggle_Click(object? sender, RoutedEventArgs e)
{
if (_player is not { } player) return;
if (player.IsPlaying) player.Pause();
else player.Play();
_state.Show();
}
}

View file

@ -3,10 +3,10 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

View file

@ -0,0 +1,87 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class CallSignalingClient : ILanCallSignaling, IDisposable
{
private readonly HubConnection _connection;
private readonly IDisposable[] _subscriptions;
private bool _disposed;
public event Action<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSignalingClient(HubConnection connection)
{
_connection = connection;
_subscriptions =
[
connection.On<CallSession>(CallHubMethods.Invitation, call => Invitation?.Invoke(call)),
connection.On<CallAccepted>(CallHubMethods.Accepted, call => Accepted?.Invoke(call)),
connection.On<CallEnded>(CallHubMethods.Ended, call => Ended?.Invoke(call)),
connection.On<Guid, CallSignal>(CallHubMethods.SignalReceived,
(id, signal) => Signal?.Invoke(id, signal))
];
connection.Reconnecting += OnInterruptedAsync;
connection.Closed += OnInterruptedAsync;
}
public Task<CallSession> InviteAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
}
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(CallHubMethods.InviteLan, room, user, ct);
}
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.Accept, callId, ct);
}
public Task EndAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.End, callId, ct);
}
public Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync(CallHubMethods.Signal, callId, signal, ct);
}
private Task OnInterruptedAsync(Exception? error)
{
Interrupted?.Invoke();
return Task.CompletedTask;
}
private void EnsureConnected()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_connection.State != HubConnectionState.Connected)
throw new InvalidOperationException("La connexion de chat est interrompue ; l'appel ne peut pas continuer.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_connection.Reconnecting -= OnInterruptedAsync;
_connection.Closed -= OnInterruptedAsync;
foreach (var subscription in _subscriptions) subscription.Dispose();
}
}

View file

@ -0,0 +1,17 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallMedia : IAsyncDisposable
{
Control LocalVideo { get; }
Control RemoteVideo { get; }
LanCallEndpoint Endpoint { get; }
Task Completion { get; }
bool HasRemoteMedia { get; }
Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct);
}

View file

@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallSignaling
{
event Action<CallSession>? Invitation;
event Action<CallAccepted>? Accepted;
event Action<CallEnded>? Ended;
event Action<Guid, CallSignal>? Signal;
event Action? Interrupted;
Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default);
Task AcceptAsync(Guid callId, CancellationToken ct = default);
Task EndAsync(Guid callId, CancellationToken ct = default);
Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default);
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Controls;
namespace PostIt.Services;
@ -8,9 +9,10 @@ public sealed record CaptureDevice(string Name, string Value);
public sealed record PodcastCaptureDevices(
IReadOnlyList<CaptureDevice> Cameras, IReadOnlyList<CaptureDevice> Microphones);
/// <summary>A running encoder publishing closed segments and an atomic index.m3u8.</summary>
/// <summary>A running camera/microphone encoder with an optional native preview.</summary>
public interface IPodcastCapture : IAsyncDisposable
{
Task Completion { get; }
Control? Preview => null;
Task StopAsync();
}

View file

@ -0,0 +1,197 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class LibVlcLanCallMedia : IAsyncDisposable
{
private readonly LibVLC _senderVlc;
private readonly LibVLC _receiverVlc;
private readonly Media _receiverMedia;
private Media? _senderMedia;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _gate = new(1);
private bool _receiving;
private bool _disposed;
private Task? _stop;
public LanCallEndpoint Endpoint { get; }
public MediaPlayer Sender { get; }
public MediaPlayer Receiver { get; }
public Task Completion => _completion.Task;
public MediaStats Statistics => _receiverMedia.Statistics;
public bool HasRemoteMedia => !_disposed && Statistics.DecodedAudio > 0 && Statistics.DecodedVideo > 0;
public LibVlcLanCallMedia(string localAddress, int port, bool headless = false)
{
if (!OperatingSystem.IsLinux() && !OperatingSystem.IsAndroidVersionAtLeast(26))
throw new PlatformNotSupportedException("Les appels SRTP LAN requierent Linux ou Android 8+.");
Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
if (!Endpoint.IsValid())
throw new ArgumentException("Adresse IPv4 privee et port UDP pair (1024-65534) requis.");
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
using (var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
{
reservation.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port));
rtcp.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port + 1));
}
Core.Initialize();
_senderVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--aout=dummy", "--intf=dummy");
try
{
_receiverVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--intf=dummy");
try
{
_receiverMedia = new Media(_receiverVlc, $"rtp://@{localAddress}:{port}", FromType.FromLocation);
_receiverMedia.AddOption(":network-caching=150");
_receiverMedia.AddOption(":rtcp-port=" + (port + 1));
_receiverMedia.AddOption(":srtp-key=" + Endpoint.Key);
_receiverMedia.AddOption(":srtp-salt=" + Endpoint.Salt);
Receiver = new MediaPlayer(_receiverVlc);
Sender = new MediaPlayer(_senderVlc);
Sender.EncounteredError += OnSenderError;
Receiver.EncounteredError += OnReceiverError;
Sender.EndReached += OnSenderEnded;
}
catch
{
_receiverMedia?.Dispose();
Receiver?.Dispose();
Sender?.Dispose();
_receiverVlc.Dispose();
throw;
}
}
catch
{
_senderVlc.Dispose();
throw;
}
}
public async Task StartReceivingAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null) throw new InvalidOperationException("Cet appel est arrete.");
if (_receiving) return;
ct.ThrowIfCancellationRequested();
if (!Receiver.Play(_receiverMedia))
throw new InvalidOperationException("Reception SRTP impossible. Verifiez adresse et port UDP.");
_receiving = true;
}
finally { _gate.Release(); }
}
public Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct) =>
StartSendingAsync(remote, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), true, ct);
internal async Task StartSendingAsync(LanCallEndpoint remote, Func<LibVLC, Media> source,
bool preview, CancellationToken ct, bool transcode = true)
{
if (!remote.IsValid())
throw new ArgumentException("Destination SRTP LAN invalide.", nameof(remote));
if (remote.Address == Endpoint.Address && remote.Port == Endpoint.Port)
throw new ArgumentException("Les deux pairs doivent avoir des ports distincts sur une meme adresse.");
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null || _senderMedia != null)
throw new InvalidOperationException("La capture de cet appel est deja demarree ou arretee.");
_senderMedia = source(_senderVlc);
ConfigureOutput(_senderMedia, remote, preview, transcode);
ct.ThrowIfCancellationRequested();
if (!Sender.Play(_senderMedia))
throw new InvalidOperationException("Capture SRTP impossible. Verifiez camera, microphone et encodeurs H.264/AAC.");
}
finally { _gate.Release(); }
}
public Task StartEncodedSendingAsync(LanCallEndpoint remote, int fileDescriptor, CancellationToken ct)
{
if (fileDescriptor < 0) throw new ArgumentOutOfRangeException(nameof(fileDescriptor));
return StartSendingAsync(remote, vlc =>
{
var media = new Media(vlc, $"fd://{fileDescriptor}", FromType.FromLocation);
media.AddOption(":demux=ts");
media.AddOption(":live-caching=100");
media.AddOption(":clock-jitter=0");
return media;
}, false, ct, transcode: false);
}
internal static void ConfigureOutput(Media media, LanCallEndpoint remote, bool preview, bool transcode = true)
{
if (!remote.IsValid()) throw new ArgumentException("Destination SRTP invalide.");
// Same capture/encoders as Podcasts, but no HLS files, segmentation or upload.
var encoding = (transcode ? "transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=30,bframes=0},"
+ "vb=1200,fps=30,acodec=mp4a,ab=96,channels=1,samplerate=48000}:" : "")
// Android LibVLC 3 crashes formatting the valueless rtcp-mux SDP attribute.
+ $"rtp{{dst={remote.Address},port={remote.Port},mux=ts,"
+ $"key={remote.Key},salt={remote.Salt},caching=50}}";
media.AddOption(":sout=#" + (preview
? $"duplicate{{dst=display{{noaudio}},dst=\"{encoding}\"}}" : encoding));
}
private void OnSenderError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Capture/encodage SRTP en echec. Verifiez les peripheriques et encodeurs."));
private void OnReceiverError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Reception/decodage SRTP en echec. Verifiez le reseau LAN/VPN et les ports UDP."));
private void OnSenderEnded(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("La source audio-video s'est terminee."));
private Task StopCoreAsync() => _stop ??= Task.Run(() =>
{
try { Sender.Stop(); }
finally { Receiver.Stop(); }
});
public async Task StopAsync()
{
await _gate.WaitAsync();
try
{
if (!_disposed) await StopCoreAsync();
}
finally { _gate.Release(); }
}
public async ValueTask DisposeAsync()
{
await _gate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
Sender.EncounteredError -= OnSenderError;
Receiver.EncounteredError -= OnReceiverError;
Sender.EndReached -= OnSenderEnded;
try { await StopCoreAsync(); }
finally
{
Sender.Dispose();
Receiver.Dispose();
_senderMedia?.Dispose();
_receiverMedia.Dispose();
_senderVlc.Dispose();
_receiverVlc.Dispose();
_completion.TrySetResult();
}
}
finally { _gate.Release(); }
}
}

View file

@ -1,6 +1,8 @@
using System;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
using Avalonia.Controls;
using Avalonia.Threading;
namespace PostIt.Services;
@ -11,24 +13,37 @@ internal sealed class LibVlcPodcastCapture : IPodcastCapture
private readonly MediaPlayer _player;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _stopped;
private bool _startScheduled;
private readonly LibVLCSharp.Avalonia.VideoView? _preview;
public Task Completion => _completion.Task;
public Control? Preview => _preview;
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string directory)
public LibVlcPodcastCapture(Func<LibVLC, Media> createMedia, string directory, bool preview = false)
{
Core.Initialize();
_vlc = CameraStreamer.CreatePreferredLibVlc();
// The usual Linux capture instance has a dummy video output; preview needs a drawable.
_vlc = preview ? new LibVLC("--avcodec-hw=none", "--aout=dummy") : CameraStreamer.CreatePreferredLibVlc();
try
{
_media = createMedia(_vlc);
PodcastBroadcaster.ConfigureOutput(_media, directory);
PodcastBroadcaster.ConfigureOutput(_media, directory, preview);
_player = new MediaPlayer(_vlc);
if (preview)
{
_preview = new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
_preview.LayoutUpdated += OnPreviewLayout;
}
_player.EncounteredError += OnError;
if (!_player.Play(_media))
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
if (!preview) StartPlayer();
}
catch
{
if (_preview is not null)
{
_preview.LayoutUpdated -= OnPreviewLayout;
_preview.MediaPlayer = null;
}
_player?.Dispose();
_media?.Dispose();
_vlc.Dispose();
@ -36,14 +51,53 @@ internal sealed class LibVlcPodcastCapture : IPodcastCapture
}
}
private void StartPlayer()
{
if (!_player.Play(_media))
throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
}
private void OnPreviewLayout(object? sender, EventArgs e)
{
if (_startScheduled || _stopped || _preview is not { IsEffectivelyVisible: true } view
|| view.Bounds.Width <= 0 || view.Bounds.Height <= 0) return;
_startScheduled = true;
view.LayoutUpdated -= OnPreviewLayout;
// LibVLC must receive its native drawable before the display branch starts.
Dispatcher.UIThread.Post(() =>
{
if (_stopped) return;
try
{
view.TryUpdateNativeControlPosition();
StartPlayer();
}
catch (Exception ex) { _completion.TrySetException(ex); }
}, DispatcherPriority.Loaded);
}
private void OnError(object? sender, EventArgs e)
=> _completion.TrySetException(new InvalidOperationException(
"La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC."));
public async Task StopAsync()
{
if (_stopped) return;
_stopped = true;
if (_preview is not null)
{
var shouldStop = await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_stopped) return false;
_stopped = true;
_preview.LayoutUpdated -= OnPreviewLayout;
return true;
});
if (!shouldStop) return;
}
else
{
if (_stopped) return;
_stopped = true;
}
await Task.Run(_player.Stop);
_completion.TrySetResult();
}
@ -53,6 +107,8 @@ internal sealed class LibVlcPodcastCapture : IPodcastCapture
try { await StopAsync(); }
finally
{
if (_preview is not null)
await Dispatcher.UIThread.InvokeAsync(() => _preview.MediaPlayer = null);
_player.EncounteredError -= OnError;
_player.Dispose();
_media.Dispose();

View file

@ -0,0 +1,105 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using LibVLCSharp.Avalonia;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class LinuxLanCallVideo : ILanCallMedia
{
private readonly LibVlcLanCallMedia _media;
private readonly VideoView _local;
private readonly VideoView _remote;
private readonly TaskCompletionSource _localReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenSource _lifetime = new();
private bool _receiverScheduled;
private bool _localScheduled;
private bool _disposed;
private readonly TaskCompletionSource _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo => _local;
public Control RemoteVideo => _remote;
public LanCallEndpoint Endpoint => _media.Endpoint;
public Task Completion { get; }
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
public LinuxLanCallVideo(string localAddress, int port)
{
_media = new LibVlcLanCallMedia(localAddress, port);
_local = new VideoView { MediaPlayer = _media.Sender, MinHeight = 140 };
_remote = new VideoView { MediaPlayer = _media.Receiver, MinHeight = 260 };
_local.LayoutUpdated += OnLocalLayout;
_remote.LayoutUpdated += OnRemoteLayout;
Completion = ObserveCompletionAsync();
}
private async Task ObserveCompletionAsync()
{
var completed = await Task.WhenAny(_media.Completion, _failure.Task);
await completed;
}
private void OnLocalLayout(object? sender, EventArgs e)
{
if (_localScheduled || _disposed || !_local.IsEffectivelyVisible ||
_local.Bounds.Width <= 0 || _local.Bounds.Height <= 0) return;
_localScheduled = true;
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
_local.TryUpdateNativeControlPosition();
_localReady.TrySetResult();
}, DispatcherPriority.Loaded);
}
private void OnRemoteLayout(object? sender, EventArgs e)
{
if (_receiverScheduled || _disposed || !_remote.IsEffectivelyVisible ||
_remote.Bounds.Width <= 0 || _remote.Bounds.Height <= 0) return;
_receiverScheduled = true;
Dispatcher.UIThread.Post(StartReceiver, DispatcherPriority.Loaded);
}
private async void StartReceiver()
{
if (_disposed) return;
try
{
_remote.TryUpdateNativeControlPosition();
await _media.StartReceivingAsync(_lifetime.Token);
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { }
catch (Exception error) { _failure.TrySetException(error); }
}
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
await _localReady.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
await _media.StartSendingAsync(remote, camera, microphone, ct);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
_localReady.TrySetCanceled();
_local.LayoutUpdated -= OnLocalLayout;
_remote.LayoutUpdated -= OnRemoteLayout;
try { await _media.StopAsync(); }
finally
{
_local.MediaPlayer = null;
_remote.MediaPlayer = null;
try { await _media.DisposeAsync(); }
finally
{
_lifetime.Dispose();
_failure.TrySetResult();
}
}
}
}

View file

@ -12,12 +12,14 @@ namespace PostIt.Services;
/// </summary>
public static class Platform
{
public static Func<PodcastCaptureDevices>? GetLanCallDevices { get; set; }
public static Func<string, int, ILanCallMedia>? CreateLanCallMedia { get; set; }
public static Func<PodcastCaptureDevices>? GetPodcastCaptureDevices { get; set; }
public static Func<string?, CancellationToken, Task>? RequestPodcastPermissionsAsync { get; set; }
public static Func<string, string?, string, CancellationToken, Task<IPodcastCapture>>? CreatePodcastCaptureAsync { get; set; }
/// <summary>Platform-specific video surface inside the shared video page.</summary>
public static Func<MediaPlayer, Control>? CreateVideoSurface { get; set; }
public static Func<MediaPlayer, bool, Control>? CreateVideoSurface { get; set; }
public static event Action? PlaybackSuspending;
public static event Action? PlaybackResuming;

View file

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using LibVLCSharp.Shared;
using Avalonia.Controls;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
@ -24,6 +25,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
private bool _disposed;
public PodcastResponse Podcast { get; private set; }
public Control? Preview => _capture.Preview;
public Task Completion { get; private set; } = Task.CompletedTask;
public event Action<PodcastResponse>? Progress;
@ -36,18 +38,24 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
_directory = directory;
}
public static void ConfigureOutput(Media media, string directory)
public static void ConfigureOutput(Media media, string directory, bool preview = false)
{
var path = directory.Replace('\\', '/').Replace("\"", "\\\"");
media.AddOption(":sout=#transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30,"
+ "acodec=mp4a,ab=128,channels=2,samplerate=48000}:std{"
var recording = "std{"
+ $"access=livehttp{{seglen=2,delsegs=false,numsegs=0,initial-segment-number=0,index=\"{path}/index.m3u8\","
+ $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}");
+ $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}";
var encoding = "transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30,"
+ "acodec=mp4a,ab=128,channels=2,samplerate=48000}:"
+ recording;
var destination = encoding.Replace("\\", "\\\\").Replace("\"", "\\\"");
media.AddOption(":sout=#" + (preview ? $"duplicate{{dst=display{{noaudio}},dst=\"{destination}\"}}" : encoding));
}
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
string camera, string? microphone, CancellationToken ct)
=> await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct);
string camera, string? microphone, CancellationToken ct, Guid? plannedId = null)
=> await StartAsync(api, title,
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct, plannedId);
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<LibVLC, Media> createMedia, CancellationToken ct)
@ -55,7 +63,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(createMedia, directory)), ct);
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct)
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct, Guid? plannedId = null)
{
IPodcastCapture? capture = null;
PodcastBroadcaster? broadcaster = null;
@ -65,7 +73,9 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
try
{
Directory.CreateDirectory(directory);
podcast = await api.CreateAsync(title, ct);
podcast = plannedId.HasValue ? await api.StartAsync(plannedId.Value, ct) : await api.CreateAsync(title, ct);
if (podcast.State != PodcastState.Live)
throw new InvalidOperationException("Cette emission est deja terminee. Preparez une nouvelle emission.");
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
ct.ThrowIfCancellationRequested();
capture = await createCapture(directory, ct);

View file

@ -0,0 +1,90 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using LibVLCSharp.Shared;
using Yavsc.Api.Client;
namespace PostIt.Services;
public sealed class PodcastReplayMediaInput : MediaInput
{
private readonly PodcastApiClient _api;
private readonly Guid _id;
private readonly long _length;
private readonly CancellationTokenSource _cancellation = new();
private byte[] _buffer = [];
private long _bufferStart;
private long _position;
public event Action<string>? Failed;
public PodcastReplayMediaInput(PodcastApiClient api, Guid id, long length)
{
_api = api;
_id = id;
_length = length;
CanSeek = true;
}
public override bool Open(out ulong size)
{
if (_length <= 0)
{
size = 0;
Failed?.Invoke("Le MP4 du replay est vide.");
return false;
}
_position = 0;
_buffer = [];
size = (ulong)_length;
return true;
}
public override int Read(IntPtr buffer, uint lengthRequested)
{
if (lengthRequested == 0) return 0;
try
{
_cancellation.Token.ThrowIfCancellationRequested();
if (_position == _length) return 0;
if (_position < _bufferStart || _position >= _bufferStart + _buffer.Length)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_cancellation.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
_buffer = _api.DownloadReplayRangeAsync(_id, _position, 256 * 1024, _length, timeout.Token)
.GetAwaiter().GetResult();
_bufferStart = _position;
}
var offset = checked((int)(_position - _bufferStart));
var count = (int)Math.Min(lengthRequested, (uint)(_buffer.Length - offset));
Marshal.Copy(_buffer, offset, buffer, count);
_position += count;
return count;
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; }
catch (Exception ex)
{
Failed?.Invoke($"Lecture du replay interrompue : {ex.Message}");
return -1;
}
}
public override bool Seek(ulong offset)
{
if (offset > (ulong)_length || _cancellation.IsCancellationRequested) return false;
_position = (long)offset;
return true;
}
public override void Close() { }
public void Cancel() => _cancellation.Cancel();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cancellation.Cancel();
_cancellation.Dispose();
}
base.Dispose(disposing);
}
}

View file

@ -0,0 +1,20 @@
using System;
namespace PostIt.Services;
public sealed class ReplayControlsState(TimeProvider? clock = null)
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private DateTimeOffset _lastInteraction = (clock ?? TimeProvider.System).GetUtcNow();
public bool IsInteracting { get; set; }
public void Show() => _lastInteraction = _clock.GetUtcNow();
public bool IsVisible(bool playing, bool focused)
=> !playing || focused || IsInteracting || _clock.GetUtcNow() - _lastInteraction < TimeSpan.FromSeconds(3);
public static string FormatTime(long milliseconds)
{
var time = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
return time.TotalHours >= 1 ? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
}
}

View file

@ -519,6 +519,14 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
HttpMethod method,
string path,
CancellationToken ct = default)
{
using var response = await SendAuthenticatedAsync(() => new HttpRequestMessage(method, path), ct)
.ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public virtual async Task<HttpResponseMessage> SendAuthenticatedAsync(
Func<HttpRequestMessage> requestFactory, CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -527,8 +535,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
using var request = requestFactory();
return await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
@ -540,10 +548,15 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
using (response)
try
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
return response;
}
catch
{
response.Dispose();
throw;
}
}

View file

@ -1,17 +1,28 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
public partial class VideoPageViewModel(Uri source, string mimeType, Uri? externalUri = null) : ViewModelBase
{
public Uri Source { get; } = source;
public Uri ExternalUri { get; } = externalUri ?? source;
public string MimeType { get; } = mimeType;
public PodcastApiClient? PodcastClient { get; init; }
public PodcastResponse? Podcast { get; init; }
public bool CanOpenExternal => Podcast is null;
public PodcastResponse? Podcast { get; set; }
public bool CanOpenExternal => CanLaunchExternally(ExternalUri);
public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => true; protected set { } }
private static bool CanLaunchExternally(Uri source)
=> source is { IsAbsoluteUri: true }
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Scheme, "https", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
}

View file

@ -37,6 +37,14 @@ namespace PostIt.ViewModels.Chat
private HubConnection? _connection;
public CallSignalingClient? Calls { get; private set; }
public bool SupportsLanCalls => Platform.CreateLanCallMedia != null ||
(OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid());
[ObservableProperty]
public partial LanCallViewModel? LanCall { get; set; }
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@ -81,6 +89,7 @@ namespace PostIt.ViewModels.Chat
partial void OnSelectedRoomChanged(string? value)
{
if (LanCall != null) LanCall.Room = value ?? "";
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
? msgs : _systemMessages;
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
@ -130,6 +139,15 @@ namespace PostIt.ViewModels.Chat
private async Task DisconnectAsync()
{
Exception? callError = null;
if (LanCall != null)
{
try { await LanCall.DisposeAsync(); }
catch (Exception error) { callError = error; }
finally { LanCall = null; }
}
Calls?.Dispose();
Calls = null;
if (_connection != null)
{
await _connection.StopAsync();
@ -142,7 +160,9 @@ namespace PostIt.ViewModels.Chat
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
Status = new StatusNotice(callError == null ? "Déconnecté" :
"Déconnecté ; erreur de fermeture de l'appel : " + callError.Message,
callError == null ? StatusSeverity.Info : StatusSeverity.Error,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
@ -150,6 +170,18 @@ namespace PostIt.ViewModels.Chat
{
try
{
if (_connection != null)
{
if (LanCall != null)
{
await LanCall.DisposeAsync();
LanCall = null;
}
Calls?.Dispose();
Calls = null;
await _connection.DisposeAsync();
_connection = null;
}
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
@ -188,6 +220,15 @@ namespace PostIt.ViewModels.Chat
.WithAutomaticReconnect()
.Build();
Calls = new CallSignalingClient(_connection);
if (SupportsLanCalls)
LanCall = new LanCallViewModel(Calls) { Room = SelectedRoom ?? "" };
else
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme."));
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Appel terminé : {call.Reason}."));
// 1. Ecoute des notifications globales (notifyUser)
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{

View file

@ -0,0 +1,332 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.ViewModels.Chat;
public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
{
private readonly ILanCallSignaling _signaling;
private readonly Func<string, int, ILanCallMedia> _createMedia;
private readonly SemaphoreSlim _events = new(1);
private readonly CancellationTokenSource _lifetime = new();
private CancellationTokenSource? _callLifetime;
private ILanCallMedia? _media;
private CallSession? _call;
private bool _disposed;
private bool _accepting;
private bool _endpointReceived;
private string? _activeCamera;
private string? _activeMicrophone;
private string? _activeAddress;
private int _activePort;
public ObservableCollection<string> Addresses { get; } = [];
public ObservableCollection<CaptureDevice> Cameras { get; } = [];
public ObservableCollection<CaptureDevice> Microphones { get; } = [];
[ObservableProperty] public partial string? Address { get; set; }
[ObservableProperty] public partial string Port { get; set; } = "5004";
[ObservableProperty] public partial CaptureDevice? Camera { get; set; }
[ObservableProperty] public partial CaptureDevice? Microphone { get; set; }
[ObservableProperty] public partial bool AllowAddressDisclosure { get; set; }
[ObservableProperty] public partial string Target { get; set; } = "";
[ObservableProperty] public partial string Room { get; set; } = "";
[ObservableProperty] public partial string Status { get; set; } = "Appel LAN/VPN : aucun appel.";
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanConfigure))]
public partial bool Incoming { get; set; }
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanConfigure))]
public partial bool Busy { get; set; }
[ObservableProperty] public partial bool Preparing { get; set; }
[ObservableProperty] public partial Control? LocalVideo { get; set; }
[ObservableProperty] public partial Control? RemoteVideo { get; set; }
public bool CanConfigure => !Busy || Incoming;
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null)
{
_signaling = signaling;
_createMedia = createMedia ?? Platform.CreateLanCallMedia ??
((address, port) => new LinuxLanCallVideo(address, port));
signaling.Invitation += OnInvitation;
signaling.Accepted += OnAccepted;
signaling.Signal += OnSignal;
signaling.Ended += OnEnded;
signaling.Interrupted += OnInterrupted;
Platform.PlaybackSuspending += OnSuspending;
}
[RelayCommand]
private void RefreshDevices()
{
try
{
if (!CanConfigure) throw new InvalidOperationException("Terminez l'appel avant de changer les peripheriques.");
Addresses.Clear();
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = Addresses.FirstOrDefault();
Cameras.Clear();
Microphones.Clear();
if (Platform.GetLanCallDevices is { } getDevices)
{
var devices = getDevices();
foreach (var device in devices.Cameras) Cameras.Add(device);
foreach (var device in devices.Microphones.Where(d => !string.IsNullOrEmpty(d.Value)))
Microphones.Add(device);
}
else
{
LibVLCSharp.Shared.Core.Initialize();
using var vlc = CameraStreamer.CreatePreferredLibVlc();
var streamer = new CameraStreamer(vlc);
foreach (var device in streamer.GetCaptureDevices()) Cameras.Add(new(device.Key, device.Value));
foreach (var device in streamer.GetAudioCaptureDevices()) Microphones.Add(new(device.Key, device.Value));
}
Camera = Cameras.FirstOrDefault();
Microphone = Microphones.FirstOrDefault();
Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
: Incoming && _call != null
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez."
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
}
catch (Exception error) { Status = "Peripheriques indisponibles : " + error.Message; }
}
private void ValidateSelection()
{
if (!AllowAddressDisclosure)
throw new InvalidOperationException("Confirmez le partage de votre adresse LAN/VPN avec le pair.");
if (Camera == null || Microphone == null)
throw new InvalidOperationException("Une camera et un microphone sont requis.");
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsPrivateIPv4(Address) ||
!int.TryParse(Port, out var port) || port is < 1024 or > 65534 || port % 2 != 0)
throw new InvalidOperationException("Selectionnez une adresse locale et un port UDP pair (1024-65534).");
_activeCamera = Camera.Value;
_activeMicrophone = Microphone.Value;
_activeAddress = Address;
_activePort = port;
}
[RelayCommand]
private Task InviteAsync() => RunAsync(async () =>
{
if (Busy) throw new InvalidOperationException("Un appel est deja en cours.");
ValidateSelection();
if (string.IsNullOrWhiteSpace(Target) || string.IsNullOrWhiteSpace(Room))
throw new InvalidOperationException("Selectionnez une salle jointe et un autre utilisateur.");
Busy = true;
_call = await _signaling.InviteLanAsync(Room, Target.Trim(), _lifetime.Token);
Status = $"Invitation envoyee a {_call.Callee} ; attente du consentement.";
});
[RelayCommand]
private Task AcceptAsync() => RunAsync(async () =>
{
if (_call == null || !Incoming)
throw new InvalidOperationException("Aucune invitation a accepter.");
ValidateSelection();
Incoming = false;
_accepting = true;
await _signaling.AcceptAsync(_call.Id, _lifetime.Token);
Status = "Invitation acceptee ; preparation des medias.";
});
[RelayCommand]
public Task HangupAsync()
{
_callLifetime?.Cancel();
return RunAsync(() => StopAsync("Appel termine.", true));
}
private void OnInvitation(CallSession call) => Queue(async () =>
{
if (call.Transport != CallTransports.LanSrtp)
{
await _signaling.EndAsync(call.Id, _lifetime.Token);
Status = "Invitation WebRTC non prise en charge par le mode LAN.";
return;
}
if (_call != null) throw new InvalidOperationException("Invitation concurrente inattendue.");
if (call.RelayOnly) throw new InvalidOperationException("Le mode LAN ne peut pas masquer les adresses IP.");
_call = call;
Busy = true;
Incoming = true;
Status = $"{call.Caller} propose un appel SRTP dans {call.RoomName}. Acceptez ou refusez.";
await Task.CompletedTask;
});
private void OnAccepted(CallAccepted accepted) => Queue(async () =>
{
if (_call?.Id != accepted.Session.Id) return;
if (_media != null) throw new InvalidOperationException("Acceptation repetee.");
if (accepted.Session.Transport != CallTransports.LanSrtp || accepted.Session.RelayOnly ||
(Incoming && !_accepting))
throw new InvalidOperationException("Acceptation ou politique de transport inattendue.");
_call = accepted.Session;
Incoming = false;
_accepting = false;
_callLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_media = _createMedia(_activeAddress!, _activePort);
LocalVideo = _media.LocalVideo;
RemoteVideo = _media.RemoteVideo;
Status = "Connexion SRTP : attente du pair (30 secondes maximum).";
await _signaling.SendAsync(_call.Id, new("rtp-endpoint", Endpoint: _media.Endpoint), _callLifetime.Token);
_ = MonitorAsync(_call.Id, _media, _callLifetime.Token);
});
private void OnSignal(Guid id, CallSignal signal) => Queue(async () =>
{
if (_call?.Id != id) return;
if (_media == null || _callLifetime == null || signal.Kind != "rtp-endpoint" ||
signal.Endpoint?.IsValid() != true || _endpointReceived)
throw new InvalidOperationException("Destination SRTP manquante, invalide ou repetee.");
_endpointReceived = true;
await _media.StartSendingAsync(signal.Endpoint, _activeCamera!, _activeMicrophone!, _callLifetime.Token);
Status = "Emission SRTP demarree ; verification de la reception audio-video.";
});
private void OnEnded(CallEnded ended) => QueueStop(ended.CallId, async () =>
{
if (_call?.Id == ended.CallId)
await StopAsync("Appel termine : " + ended.Reason, false);
});
private void OnInterrupted() => QueueStop(null,
() => StopAsync("Connexion SignalR interrompue ; medias arretes.", false));
private void OnSuspending() => QueueStop(null,
() => StopAsync("Application en arriere-plan ; appel termine.", true));
private void QueueStop(Guid? id, Func<Task> action) => Dispatcher.UIThread.Post(async () =>
{
if (id == null || _call?.Id == id) _callLifetime?.Cancel();
await RunAsync(action);
});
private async Task MonitorAsync(Guid id, ILanCallMedia media, CancellationToken ct)
{
var start = Stopwatch.StartNew();
var received = false;
try
{
while (!ct.IsCancellationRequested)
{
if (media.Completion.IsCompleted) await media.Completion;
if (!received && media.HasRemoteMedia)
{
received = true;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_call?.Id == id) Status = "Reception audio-video SRTP etablie (LAN/VPN).";
});
}
if (!received && start.Elapsed > TimeSpan.FromSeconds(30))
throw new TimeoutException("Aucun media distant : verifiez adresses, port UDP et pare-feu LAN/VPN.");
await Task.Delay(500, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception error)
{
Queue(() => _call?.Id == id && ReferenceEquals(_media, media)
? FailAsync(error) : Task.CompletedTask);
}
}
private void Queue(Func<Task> action) =>
Dispatcher.UIThread.Post(async () => await RunAsync(action));
private async Task RunAsync(Func<Task> action)
{
await _events.WaitAsync();
try
{
if (_disposed) return;
Preparing = true;
await action();
}
catch (OperationCanceledException) when (
_lifetime.IsCancellationRequested || _callLifetime?.IsCancellationRequested == true) { }
catch (Exception error) { await FailAsync(error); }
finally
{
Preparing = false;
_events.Release();
}
}
private async Task FailAsync(Exception error)
{
try { await StopAsync("Echec de l'appel : " + error.Message, true); }
catch (Exception cleanupError)
{
Status = $"Echec de l'appel : {error.Message}. Nettoyage/signalisation : {cleanupError.Message}";
}
}
private async Task StopAsync(string status, bool notify)
{
var call = _call;
_call = null;
Incoming = false;
Busy = false;
_accepting = false;
_endpointReceived = false;
_callLifetime?.Cancel();
var media = _media;
_media = null;
try
{
if (media != null) await media.DisposeAsync();
}
finally
{
LocalVideo = null;
RemoteVideo = null;
_callLifetime?.Dispose();
_callLifetime = null;
Status = status;
}
if (notify && call != null)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _signaling.EndAsync(call.Id, timeout.Token);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_lifetime.Cancel();
await _events.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
_signaling.Invitation -= OnInvitation;
_signaling.Accepted -= OnAccepted;
_signaling.Signal -= OnSignal;
_signaling.Ended -= OnEnded;
_signaling.Interrupted -= OnInterrupted;
Platform.PlaybackSuspending -= OnSuspending;
try { await StopAsync("Appel arrete.", true); }
finally { _lifetime.Dispose(); }
}
finally { _events.Release(); }
}
}

View file

@ -91,6 +91,9 @@ public partial class UserProfilePageViewModel : ViewModelBase
[ObservableProperty]
public partial bool AllowMonthlyEmail { get; set; }
[ObservableProperty]
public partial bool HideIpAddressInCalls { get; set; }
[ObservableProperty]
public partial bool HasPassword { get; set; }
@ -195,6 +198,7 @@ public partial class UserProfilePageViewModel : ViewModelBase
BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary;
EmailConfirmed = me.EmailConfirmed;
AllowMonthlyEmail = me.AllowMonthlyEmail;
HideIpAddressInCalls = me.HideIpAddressInCalls;
HasPassword = me.HasPassword;
TwoFactorEnabled = me.TwoFactorEnabled;
ExternalLoginCount = me.ExternalLoginCount;
@ -280,7 +284,8 @@ public partial class UserProfilePageViewModel : ViewModelBase
UserName = NormalizeField(UserName, "—"),
FullName = NormalizeField(FullName, "—"),
Address = NormalizeField(Address, "—"),
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré"),
HideIpAddressInCalls = HideIpAddressInCalls
};
}

View file

@ -50,6 +50,9 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
[ObservableProperty]
public partial string Website { get; set; } = string.Empty;
[ObservableProperty]
public partial string DonationPayPalEmail { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Active { get; set; }
@ -101,6 +104,7 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
SIREN = dto?.SIREN ?? string.Empty;
Website = dto?.WebSite ?? string.Empty;
DonationPayPalEmail = dto?.DonationPayPalEmail ?? string.Empty;
Active = dto?.Active ?? false;
AcceptNotifications = dto?.AcceptNotifications ?? false;
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
@ -142,6 +146,7 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
DonationPayPalEmail = DonationPayPalEmail.Trim(),
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,

View file

@ -1,9 +1,11 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -30,6 +32,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
private CaptureDevice? _camera;
private CaptureDevice? _microphone;
private PodcastResponse? _selected;
private Control? _capturePreview;
private bool _restoringPreferences;
public Control? CapturePreview { get => _capturePreview; private set => SetProperty(ref _capturePreview, value); }
public ObservableCollection<PodcastResponse> Podcasts { get; } = new();
public ObservableCollection<CaptureDevice> Cameras { get; } = new();
@ -38,8 +43,31 @@ public partial class PodcastsPageViewModel : ViewModelBase
|| (!OperatingSystem.IsAndroid() && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()));
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
public string Title
{
get => _title;
set
{
if (!SetProperty(ref _title, value)) return;
UpdateCommands();
if (!_restoringPreferences)
{
_api.Settings.PodcastDefaultTitle = value;
SavePreferences();
}
}
}
[ObservableProperty]
public partial DateTimeOffset? ScheduledDate { get; set; } = DateTimeOffset.Now.AddDays(1);
[ObservableProperty]
public partial TimeSpan? ScheduledTime { get; set; } = new TimeSpan(20, 0, 0);
[ObservableProperty]
public partial decimal PlannedDurationMinutes { get; set; } = 60;
[ObservableProperty]
public partial bool PublishAnnouncement { get; set; }
[ObservableProperty]
public partial string Announcement { get; set; } = "";
public string Status { get => _status; private set => SetProperty(ref _status, value); }
public bool IsBroadcasting
@ -57,28 +85,64 @@ public partial class PodcastsPageViewModel : ViewModelBase
public CaptureDevice? SelectedCamera
{
get => _camera;
set { if (SetProperty(ref _camera, value)) UpdateCommands(); }
set
{
if (!SetProperty(ref _camera, value)) return;
UpdateCommands();
if (!_restoringPreferences && value != null)
{
_api.Settings.PodcastCameraId = value.Value;
SavePreferences();
}
}
}
public CaptureDevice? SelectedMicrophone
{
get => _microphone;
set { if (SetProperty(ref _microphone, value)) UpdateCommands(); }
set
{
if (!SetProperty(ref _microphone, value)) return;
UpdateCommands();
if (!_restoringPreferences && value != null)
{
_api.Settings.PodcastMicrophoneId = value.Value;
SavePreferences();
}
}
}
public PodcastResponse? SelectedPodcast
{
get => _selected;
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
set
{
if (!SetProperty(ref _selected, value)) return;
if (value is { State: PodcastState.Planned } && value.OwnerId == _ownerId)
{
_title = value.Title;
OnPropertyChanged(nameof(Title));
ScheduledDate = value.ScheduledStartAt?.ToLocalTime();
ScheduledTime = value.ScheduledStartAt?.ToLocalTime().TimeOfDay;
PlannedDurationMinutes = value.PlannedDurationMinutes ?? 60;
Announcement = value.PublicAnnouncement ?? "";
PublishAnnouncement = !string.IsNullOrEmpty(Announcement);
}
UpdateCommands();
}
}
public IAsyncRelayCommand RefreshCommand { get; }
public IRelayCommand DetectDevicesCommand { get; }
public IAsyncRelayCommand StartCommand { get; }
public IAsyncRelayCommand PrepareCommand { get; }
public IAsyncRelayCommand UpdatePlanCommand { get; }
public IRelayCommand NewPlanCommand { get; }
public IAsyncRelayCommand StopCommand { get; }
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand RetryReplayCommand { get; }
public override bool CanNavigateNext { get => false; protected set { } }
public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } }
@ -91,15 +155,54 @@ public partial class PodcastsPageViewModel : ViewModelBase
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title)
&& (SelectedPodcast == null || SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId));
PrepareCommand = new AsyncRelayCommand(() => SavePlanAsync(false),
() => !IsBusy && !IsBroadcasting && _session.IsLoggedIn && !string.IsNullOrWhiteSpace(Title));
UpdatePlanCommand = new AsyncRelayCommand(() => SavePlanAsync(true),
() => !IsBusy && !IsBroadcasting && SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId);
NewPlanCommand = new RelayCommand(() =>
{
SelectedPodcast = null;
Title = _api.Settings.PodcastDefaultTitle;
}, () => !IsBusy && !IsBroadcasting);
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
FinishCommand = new AsyncRelayCommand(FinishAsync,
() => SelectedPodcast is { State: not PodcastState.Completed } p
() => SelectedPodcast is { State: PodcastState.Live or PodcastState.Interrupted } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
RetryReplayCommand = new AsyncRelayCommand(RetryReplayAsync,
() => SelectedPodcast is { State: PodcastState.Completed, Replay.State: PodcastReplayState.Failed } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
_title = _api.Settings.PodcastDefaultTitle;
_restoringPreferences = true;
try
{
foreach (var device in _api.Settings.PodcastCameras) Cameras.Add(device);
foreach (var device in _api.Settings.PodcastMicrophones) Microphones.Add(device);
RestoreDeviceSelection();
}
finally { _restoringPreferences = false; }
}
private void RestoreDeviceSelection()
{
SelectedCamera = Cameras.FirstOrDefault(d => d.Value == _api.Settings.PodcastCameraId)
?? Cameras.FirstOrDefault();
SelectedMicrophone = Microphones.FirstOrDefault(d => d.Value == _api.Settings.PodcastMicrophoneId)
?? (Microphones.Count > 1 ? Microphones[1] : Microphones.FirstOrDefault());
}
private void SavePreferences()
{
try { _api.Settings.Save(); }
catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException)
{
Status = $"Preferences podcast appliquees, mais sauvegarde impossible : {ex.Message}";
}
}
private void UpdateCommands()
@ -107,10 +210,14 @@ public partial class PodcastsPageViewModel : ViewModelBase
RefreshCommand.NotifyCanExecuteChanged();
DetectDevicesCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
PrepareCommand.NotifyCanExecuteChanged();
UpdatePlanCommand.NotifyCanExecuteChanged();
NewPlanCommand.NotifyCanExecuteChanged();
StopCommand.NotifyCanExecuteChanged();
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
RetryReplayCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(CanNavigatePrevious));
}
@ -130,6 +237,34 @@ public partial class PodcastsPageViewModel : ViewModelBase
finally { IsBusy = false; }
}
private async Task SavePlanAsync(bool update)
{
IsBusy = true;
try
{
if (ScheduledDate == null || ScheduledTime == null || PlannedDurationMinutes != decimal.Truncate(PlannedDurationMinutes)
|| PlannedDurationMinutes is < 1 or > int.MaxValue)
throw new InvalidOperationException("Choisissez un debut et une duree entiere positive en minutes.");
if (PublishAnnouncement && string.IsNullOrWhiteSpace(Announcement))
throw new InvalidOperationException("Saisissez le texte de l'annonce publique ou desactivez sa publication.");
var local = DateTime.SpecifyKind(ScheduledDate.Value.Date + ScheduledTime.Value, DateTimeKind.Unspecified);
if (TimeZoneInfo.Local.IsInvalidTime(local) || TimeZoneInfo.Local.IsAmbiguousTime(local))
throw new InvalidOperationException("Cette heure est inexistante ou ambigue lors du changement d'heure. Choisissez une autre heure.");
var start = new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local));
var plan = await _client.PrepareAsync(new PreparePodcastRequest
{
Title = Title, ScheduledStartAt = start,
PlannedDurationMinutes = (int)PlannedDurationMinutes,
PublicAnnouncement = PublishAnnouncement ? Announcement : null
}, update ? SelectedPodcast?.Id : null, _pageCancellation.Token);
await RefreshAsync();
SelectedPodcast = plan;
Status = "Emission preparee. Debut et duree previsionnels : demarrage et arret manuels.";
}
catch (Exception ex) { Status = $"Preparation impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
public async Task ActivateAsync()
{
if (_pageCancellation.IsCancellationRequested)
@ -148,6 +283,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
private void DetectDevices()
{
_restoringPreferences = true;
try
{
Cameras.Clear();
@ -167,11 +303,16 @@ public partial class PodcastsPageViewModel : ViewModelBase
Microphones.Add(new("Sans microphone", ""));
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
}
SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
RestoreDeviceSelection();
_api.Settings.PodcastCameras = Cameras.ToArray();
_api.Settings.PodcastMicrophones = Microphones.ToArray();
if (SelectedCamera != null) _api.Settings.PodcastCameraId = SelectedCamera.Value;
if (SelectedMicrophone != null) _api.Settings.PodcastMicrophoneId = SelectedMicrophone.Value;
Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer.";
SavePreferences();
}
catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; }
finally { _restoringPreferences = false; }
}
private async Task StartAsync()
@ -186,9 +327,10 @@ public partial class PodcastsPageViewModel : ViewModelBase
await requestPermissions(microphone, _pageCancellation.Token);
_broadcaster = Platform.CreatePodcastCaptureAsync is { } createCapture
? await PodcastBroadcaster.StartAsync(_client, Title,
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token, SelectedPodcast?.Id)
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token, SelectedPodcast?.Id);
_broadcaster.Progress += OnProgress;
CapturePreview = _broadcaster.Preview;
_session.LogoutCompleted += OnLogout;
IsBroadcasting = true;
Status = "Capture en cours ; attente du premier segment HLS.";
@ -220,6 +362,7 @@ public partial class PodcastsPageViewModel : ViewModelBase
}
finally
{
CapturePreview = null;
broadcaster.Progress -= OnProgress;
_session.LogoutCompleted -= OnLogout;
try { await broadcaster.DisposeAsync(); }
@ -253,7 +396,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible.");
var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8");
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
var catalogueUrl = new Uri(new Uri(_api.Settings.Authentication.Authority.TrimEnd('/') + "/"), "Podcast");
await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl", catalogueUrl)
{
PodcastClient = _client, Podcast = podcast
});
@ -287,4 +431,17 @@ public partial class PodcastsPageViewModel : ViewModelBase
catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
private async Task RetryReplayAsync()
{
IsBusy = true;
try
{
var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast.");
await _client.RetryReplayAsync(podcast.Id);
await RefreshAsync();
}
catch (Exception ex) { Status = $"Relance du MP4 impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
}

View file

@ -10,6 +10,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
using PostIt.Models;
using PostIt.Services;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@ -17,6 +18,10 @@ namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
public Settings() { }
internal Settings(string settingsFileFullName) => SettingsFileFullName = settingsFileFullName;
[JsonIgnore]
public string? SettingsFileFullName { get; private set; }
@ -41,6 +46,17 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty;
[ObservableProperty]
public partial string PodcastDefaultTitle { get; set; } = string.Empty;
[ObservableProperty]
public partial CaptureDevice[] PodcastCameras { get; set; } = [];
[ObservableProperty]
public partial CaptureDevice[] PodcastMicrophones { get; set; } = [];
[ObservableProperty]
public partial string? PodcastCameraId { get; set; }
[ObservableProperty]
public partial string? PodcastMicrophoneId { get; set; }
[ObservableProperty]
[JsonIgnore]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
@ -135,6 +151,11 @@ public partial class Settings : ViewModelBase
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty();
partial void OnPodcastDefaultTitleChanged(string value) => MarkDirty();
partial void OnPodcastCamerasChanged(CaptureDevice[] value) => MarkDirty();
partial void OnPodcastMicrophonesChanged(CaptureDevice[] value) => MarkDirty();
partial void OnPodcastCameraIdChanged(string? value) => MarkDirty();
partial void OnPodcastMicrophoneIdChanged(string? value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -392,6 +413,11 @@ public partial class Settings : ViewModelBase
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty;
PodcastDefaultTitle = settings.PodcastDefaultTitle ?? string.Empty;
PodcastCameras = settings.PodcastCameras ?? [];
PodcastMicrophones = settings.PodcastMicrophones ?? [];
PodcastCameraId = settings.PodcastCameraId;
PodcastMicrophoneId = settings.PodcastMicrophoneId;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();

View file

@ -10,13 +10,14 @@
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
xmlns:services="using:PostIt.Services"
x:DataType="vm:ChatViewModel"
>
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto, Auto">
<Grid
Grid.Row="1"
@ -102,9 +103,55 @@
Content="Envoyer" />
</Grid>
<Expander Grid.Row="3" Header="Appel direct (LAN/VPN)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
<ScrollViewer MaxHeight="520">
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8">
<TextBlock Text="Android et Desktop Linux sur LAN/VPN. SRTP chiffre les medias ; le pair connait votre IP. Ouvrir le port UDP choisi et le suivant. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
TextWrapping="Wrap" />
<WrapPanel IsEnabled="{Binding CanConfigure}" IsVisible="{Binding !RemoteVideo}">
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}"
PlaceholderText="Adresse locale LAN/VPN" Margin="0,0,8,4" />
<TextBox Width="80" Text="{Binding Port}" PlaceholderText="Port pair" Margin="0,0,8,4" />
<ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}"
PlaceholderText="Camera" Margin="0,0,8,4">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Width="190" ItemsSource="{Binding Microphones}" SelectedItem="{Binding Microphone}"
PlaceholderText="Microphone" Margin="0,0,8,4">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" />
</WrapPanel>
<CheckBox Content="Je consens a partager mon adresse LAN/VPN avec le pair."
IsChecked="{Binding AllowAddressDisclosure}" IsEnabled="{Binding CanConfigure}"
IsVisible="{Binding !RemoteVideo}" />
<WrapPanel>
<Button Content="Appeler dans la salle active" Command="{Binding InviteCommand}" IsEnabled="{Binding !Busy}" Margin="0,0,8,4" />
<Button Content="Accepter" Command="{Binding AcceptCommand}" IsEnabled="{Binding Incoming}" Margin="0,0,8,4" />
<Button Content="Refuser / Raccrocher" Command="{Binding HangupCommand}" IsEnabled="{Binding Busy}" Margin="0,0,8,4" />
</WrapPanel>
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,2*" Height="280" IsVisible="{Binding !!RemoteVideo}">
<ContentControl Content="{Binding LocalVideo}" Margin="0,0,8,0" />
<ContentControl Grid.Column="1" Content="{Binding RemoteVideo}" />
</Grid>
</StackPanel>
</ScrollViewer>
</Expander>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="3"
Grid.Row="4"
DataContext="{Binding Status}" />
</Grid>

View file

@ -1,4 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using PostIt.ViewModels.Chat;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
@ -8,4 +10,10 @@ public partial class ChatPage : ContentPage
InitializeComponent();
}
protected override async void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (DataContext is ChatViewModel { LanCall: { } call })
await call.HangupAsync();
}
}

View file

@ -27,6 +27,16 @@
<TextBox Text="{Binding Website, Mode=TwoWay}"
PlaceholderText="Site web"/>
<TextBlock Text="Dons PayPal" FontWeight="SemiBold"/>
<TextBlock Text="Adresse e-mail PayPal du bénéficiaire"/>
<TextBox x:Name="DonationPayPalEmailInput"
Text="{Binding DonationPayPalEmail, Mode=TwoWay}"
PlaceholderText="Adresse e-mail de votre compte PayPal"
MaxLength="127"
IsEnabled="{Binding !IsBusy}"/>
<TextBlock Text="Les dons sont versés directement sur ce compte PayPal. Vérifiez l’adresse. Laissez vide pour désactiver les nouveaux dons, puis enregistrez. Le profil doit être actif pour proposer les dons."
TextWrapping="Wrap"/>
<CheckBox Content="Disponible / actif"
IsChecked="{Binding Active, Mode=TwoWay}"/>
<CheckBox Content="Accepter les notifications"

View file

@ -136,6 +136,16 @@
TextWrapping="Wrap"/>
</Grid>
<StackPanel Spacing="4" IsEnabled="{Binding IsProfileLoaded}">
<TextBlock Text="Confidentialité des futurs appels audio-vidéo"
FontWeight="SemiBold"/>
<CheckBox Content="Masquer mon adresse IP aux autres participants"
IsChecked="{Binding HideIpAddressInCalls, Mode=TwoWay}"
IsEnabled="{Binding !IsBusy}"/>
<TextBlock Text="Préférence enregistrée avec le profil et partagée entre vos appareils. Les appels ne sont pas encore disponibles. Lors de leur mise en service, cette option imposera un relais TURN, avec une latence potentiellement accrue ; les serveurs contactés pourront toujours connaître votre adresse IP."
TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir un avatar"

View file

@ -1,56 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
x:Class="PostIt.Views.PodcastsPage"
x:DataType="vm:PodcastsPageViewModel"
Header="Podcasts">
<ScrollViewer>
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
</StackPanel>
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"
SelectionChanged="PodcastSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="streaming:PodcastResponse">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel>
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox x:Name="ConfirmDelete" Content="Confirmer la suppression du replay selectionne" />
<Button Content="Supprimer mon replay" Command="{Binding DeleteCommand}" IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,137 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
xmlns:streaming="clr-namespace:Yavsc.Abstract.Streaming;assembly=Yavsc.Abstract"
x:Class="PostIt.Views.PodcastsPage"
x:DataType="vm:PodcastsPageViewModel"
Header="Podcasts">
<ContentPage.Styles>
<Style Selector="Border.warning-danger">
<Setter Property="Background" Value="#862C05"/>
<Setter Property="BorderBrush" Value="#A5EDC2"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="12"/>
</Style>
<Style Selector="TextBlock.warning-danger">
<Setter Property="Background" Value="#862C05"/>
<Setter Property="Foreground" Value="#AAFF00"/>
</Style>
<Style Selector="TextBlock.warning-title">
<Setter Property="Background" Value="#862C05"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="CheckBox.warning-check">
<Setter Property="Background" Value="#862C05"/>
<Setter Property="Foreground" Value="#FFF7F4"/>
</Style>
<Style Selector="Button.delete-danger">
<Setter Property="Background" Value="#862C05"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="10,8"/>
</Style>
<Style Selector="Button.delete-danger:pointerover">
<Setter Property="Background" Value="#FFCB64"/>
<Setter Property="Foreground" Value="#03963E"/>
</Style>
<Style Selector="Button.delete-danger:disabled">
<Setter Property="Opacity" Value="0.55"/>
</Style>
</ContentPage.Styles>
<ScrollViewer>
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
<StackPanel Spacing="8" IsEnabled="{Binding !IsBroadcasting}">
<TextBlock Text="Preparer une emission" FontWeight="SemiBold" />
<TextBox Text="{Binding Title, Mode=TwoWay}" PlaceholderText="Titre de l'emission" MaxLength="160" />
<TextBlock Text="Debut prevu (heure locale)" />
<DatePicker SelectedDate="{Binding ScheduledDate, Mode=TwoWay}" />
<TimePicker SelectedTime="{Binding ScheduledTime, Mode=TwoWay}" />
<TextBlock Text="Duree prevue en minutes" />
<NumericUpDown Value="{Binding PlannedDurationMinutes, Mode=TwoWay}" Minimum="1" Maximum="2147483647" Increment="1" />
<CheckBox Content="Publier une annonce visible sans connexion sur Yavsc.Org"
IsChecked="{Binding PublishAnnouncement, Mode=TwoWay}" />
<TextBox Text="{Binding Announcement, Mode=TwoWay}" MaxLength="1023" AcceptsReturn="True"
IsVisible="{Binding PublishAnnouncement}" PlaceholderText="Annonce publique (ne pas inclure d'informations privees)" />
<TextBlock Text="Sans annonce, la preparation est privee. Les horaires sont previsionnels : demarrage et arret manuels. L'ecoute reste reservee aux utilisateurs connectes."
TextWrapping="Wrap" />
<WrapPanel>
<Button Content="Nouvelle emission / direct immediat" Command="{Binding NewPlanCommand}" Margin="0,0,8,8" />
<Button Content="Preparer une nouvelle emission" Command="{Binding PrepareCommand}" Margin="0,0,8,8" />
<Button Content="Enregistrer les modifications de l'emission selectionnee" Command="{Binding UpdatePlanCommand}" />
</WrapPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding IsBroadcasting}">
<TextBlock Text="Apercu local de la capture" FontWeight="SemiBold" />
<Border Background="Black" CornerRadius="4" ClipToBounds="True">
<ContentControl x:Name="CapturePreviewHost" Content="{Binding CapturePreview}" Height="300"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch" />
</Border>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox ItemsSource="{Binding Microphones}" SelectedItem="{Binding SelectedMicrophone}" PlaceholderText="Microphone">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice"><TextBlock Text="{Binding Name}" /></DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Demarrer le direct" Command="{Binding StartCommand}" />
</StackPanel>
<Button Content="Arreter et publier le replay" Command="{Binding StopCommand}" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Button Content="Actualiser les directs et replays" Command="{Binding RefreshCommand}" />
<ListBox ItemsSource="{Binding Podcasts}" SelectedItem="{Binding SelectedPodcast}" MinHeight="160" MaxHeight="420"
SelectionChanged="PodcastSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="streaming:PodcastResponse">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{Binding OwnerName}" />
<TextBlock Text="{Binding State}" />
<TextBlock Text="{Binding DurationSeconds, StringFormat='{}{0:0} s'}" />
<TextBlock Text="{Binding Replay.State, StringFormat='MP4 : {0}'}" />
</StackPanel>
<TextBlock Text="{Binding Replay.Error}" TextWrapping="Wrap" />
<TextBlock Text="{Binding ScheduledStartAt, StringFormat='Debut prevu : {0:g}'}" />
<TextBlock Text="{Binding PlannedDurationMinutes, StringFormat='Duree prevue : {0} min'}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<WrapPanel>
<Button Content="Regarder le direct / replay" Command="{Binding PlayCommand}" Margin="0,0,8,8" />
<Button Content="Terminer mon direct selectionne" Command="{Binding FinishCommand}" Margin="0,0,8,8" />
<Button Content="Relancer la preparation MP4" Command="{Binding RetryReplayCommand}" Margin="0,0,8,8" />
</WrapPanel>
<Border Classes="warning-danger">
<StackPanel Spacing="8">
<TextBlock Classes="warning-title" Text="⚠ Suppression du podcast / emission preparee" />
<TextBlock Text="Cette action supprime définitivement le podcast ou l'émission préparée sélectionnée. Cette opération est irréversible."
TextWrapping="Wrap" />
<CheckBox x:Name="ConfirmDelete"
Classes="warning-check"
Content="Je confirme la suppression du podcast sélectionné" />
<Button Classes="delete-danger"
Content="Supprimer mon podcast"
Command="{Binding DeleteCommand}"
IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@ -15,6 +17,8 @@ public partial class VideoPage : ContentPage
private LibVLC? _libVlc;
private MediaPlayer? _player;
private PodcastMediaInput? _podcastInput;
private PodcastReplayMediaInput? _replayInput;
private CancellationTokenSource? _initialization;
public VideoPage() => InitializeComponent();
@ -26,28 +30,62 @@ public partial class VideoPage : ContentPage
InitializePlayer();
}
private void InitializePlayer()
private async void InitializePlayer()
{
ReleasePlayer();
if (Design.IsDesignMode || DataContext is not VideoPageViewModel vm)
return;
var cancellation = new CancellationTokenSource();
_initialization = cancellation;
var ct = cancellation.Token;
try
{
var replay = false;
var podcast = vm.Podcast;
if (podcast is not null && vm.PodcastClient is { } replayClient)
{
while (true)
{
var manifest = await replayClient.GetAsync(podcast.Id, ct);
ct.ThrowIfCancellationRequested();
podcast = manifest.Podcast;
if (podcast.State != PodcastState.Completed) break;
PlaybackStatus.Text = "Preparation du replay MP4...";
if (manifest.Podcast.Replay is { State: PodcastReplayState.Ready, Bytes: > 0 } ready)
{
_replayInput = new PodcastReplayMediaInput(replayClient, podcast.Id, ready.Bytes);
_replayInput.Failed += PodcastFailed;
replay = true;
break;
}
if (manifest.Podcast.Replay is { State: PodcastReplayState.Failed } failed)
{
PlaybackStatus.Text = failed.Error ?? "Preparation du MP4 en echec.";
return;
}
await Task.Delay(1000, ct);
}
}
ct.ThrowIfCancellationRequested();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
if (!replay && podcast is { } retained && vm.PodcastClient is { } client)
{
_podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
_podcastInput = new PodcastMediaInput(client, retained.Id, retained.State == PodcastState.Live);
_podcastInput.Failed += PodcastFailed;
}
using var media = _podcastInput is null
? new Media(_libVlc, vm.Source)
: new Media(_libVlc, _podcastInput);
using var media = _replayInput is not null ? new Media(_libVlc, _replayInput)
: _podcastInput is not null ? new Media(_libVlc, _podcastInput)
: new Media(_libVlc, vm.Source);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
var surface = Platform.CreateVideoSurface?.Invoke(_player)
?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
var surface = Platform.CreateVideoSurface?.Invoke(_player, replay)
?? new LibVLCSharp.Avalonia.VideoView
{
MediaPlayer = _player,
Content = replay ? new PostIt.Controls.ReplayControls(_player) : null
};
VideoSurface.Content = surface;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
@ -57,14 +95,16 @@ public partial class VideoPage : ContentPage
desktopVideo.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
PlaybackStatus.Text = replay ? "video/mp4" : vm.MimeType;
if (!_player.Play(media))
PlaybackStatus.Text = vm.Podcast is null
? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."
: "Impossible de demarrer la lecture du podcast.";
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception ex)
{
if (!ReferenceEquals(_initialization, cancellation)) return;
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
@ -88,8 +128,12 @@ public partial class VideoPage : ContentPage
private void ReleasePlayer()
{
_initialization?.Cancel();
_initialization?.Dispose();
_initialization = null;
PlayButton.IsEnabled = false;
_podcastInput?.Cancel();
_replayInput?.Cancel();
if (VideoSurface.Content is LibVLCSharp.Avalonia.VideoView desktopVideo)
desktopVideo.MediaPlayer = null;
if (VideoSurface.Content is IDisposable nativeSurface)
@ -110,6 +154,12 @@ public partial class VideoPage : ContentPage
}
_libVlc?.Dispose();
_libVlc = null;
if (_replayInput is not null)
{
_replayInput.Failed -= PodcastFailed;
_replayInput.Dispose();
_replayInput = null;
}
}
private void SuspendPlayback()
@ -122,15 +172,6 @@ public partial class VideoPage : ContentPage
private void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message);
private static bool CanLaunchExternally(Uri source)
=> source is { IsAbsoluteUri: true }
&& (string.Equals(source.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Scheme, "https", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase)
&& !source.AbsolutePath.Contains("/api/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase);
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
@ -158,15 +199,15 @@ public partial class VideoPage : ContentPage
return;
try
{
if (!CanLaunchExternally(vm.Source))
if (!vm.CanOpenExternal)
{
PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur.";
PlaybackStatus.Text = "Cette ressource ne peut pas être ouverte dans le navigateur.";
return;
}
var topLevel = TopLevel.GetTopLevel(this)
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
if (!await topLevel.Launcher.LaunchUriAsync(vm.ExternalUri))
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
}
catch (Exception ex)

View file

@ -0,0 +1,61 @@
#nullable enable
namespace Yavsc.Abstract.Chat.Calls;
public static class CallHubMethods
{
public const string Invite = "InviteCall";
public const string InviteLan = "InviteLanCall";
public const string Accept = "AcceptCall";
public const string End = "EndCall";
public const string Signal = "SendCallSignal";
public const string Invitation = "CallInvitation";
public const string Accepted = "CallAccepted";
public const string Ended = "CallEnded";
public const string SignalReceived = "CallSignal";
}
public sealed record CallSession(
Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly,
DateTimeOffset ExpiresAt, string Transport = CallTransports.WebRtc);
public static class CallTransports
{
public const string WebRtc = "webrtc";
public const string LanSrtp = "lan-srtp";
}
public sealed record CallIceServer(string[] Urls, string? Username = null, string? Credential = null);
public sealed record CallAccepted(CallSession Session, CallIceServer[] IceServers);
public sealed record CallEnded(Guid CallId, string Reason);
public sealed record CallSignal(
string Kind, string? Sdp = null, string? Candidate = null,
string? SdpMid = null, int? SdpMLineIndex = null, LanCallEndpoint? Endpoint = null);
public sealed record LanCallEndpoint(string Address, int Port, string Key, string Salt)
{
public override string ToString() => "SRTP endpoint (credentials redacted)";
public bool IsValid() =>
LanCallNetwork.IsPrivateIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
Key is { Length: 32 } && Key.All(Uri.IsHexDigit) &&
Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit);
}
public static class LanCallNetwork
{
public static bool IsPrivateIPv4(string address)
{
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork ||
ip.ToString() != address)
return false;
var bytes = ip.GetAddressBytes();
return bytes[0] == 10 || bytes[0] == 127 ||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
(bytes[0] == 192 && bytes[1] == 168);
}
}

View file

@ -5,6 +5,7 @@ namespace Yavsc.Abstract.Identity {
{
public bool EmailConfirmed { get; set; }
public bool AllowMonthlyEmail { get; set; }
public bool HideIpAddressInCalls { get; set; }
public bool HasPassword { get; set; }
public bool TwoFactorEnabled { get; set; }
public int ExternalLoginCount { get; set; }

View file

@ -9,4 +9,5 @@ public class ProfileUpdateRequest
public string? Address { get; set; }
public string? GoogleCalendarId { get; set; }
public string? BankInfoSummary { get; set; }
public bool? HideIpAddressInCalls { get; set; }
}

View file

@ -5,7 +5,14 @@ namespace Yavsc.Abstract.Streaming;
/// <summary>The lifecycle of a retained live broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastState>))]
public enum PodcastState { Live, Completed, Interrupted }
public enum PodcastState { Live, Completed, Interrupted, Planned }
/// <summary>Preparation state of the seekable MP4 copy of a completed broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastReplayState>))]
public enum PodcastReplayState { Pending, Processing, Ready, Failed }
/// <summary>Persistent MP4 preparation result; source segments are retained.</summary>
public sealed record PodcastReplayResponse(PodcastReplayState State, long Bytes = 0, string? Error = null);
/// <summary>Creates an authenticated live broadcast with a retained replay.</summary>
public sealed record CreatePodcastRequest
@ -14,11 +21,35 @@ public sealed record CreatePodcastRequest
public required string Title { get; init; }
}
/// <summary>Prepares or edits an emission before its manual start.</summary>
public sealed record PreparePodcastRequest
{
[Required, StringLength(160, MinimumLength = 1)]
public required string Title { get; init; }
public required DateTimeOffset ScheduledStartAt { get; init; }
[Range(1, int.MaxValue)]
public required int PlannedDurationMinutes { get; init; }
[StringLength(1023)]
public string? PublicAnnouncement { get; init; }
}
/// <summary>Public presentation only; never includes playback addresses or private metadata.</summary>
public sealed record PodcastAnnouncementResponse(
Guid Id, string Title, string OwnerName, DateTimeOffset ScheduledStartAt,
int PlannedDurationMinutes, string Announcement);
/// <summary>A broadcast available to authenticated listeners.</summary>
public sealed record PodcastResponse(
Guid Id, string Title, string OwnerId, string OwnerName,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, PodcastState State,
int SegmentCount, long Bytes, double DurationSeconds);
int SegmentCount, long Bytes, double DurationSeconds)
{
public long? FlowId { get; init; }
public DateTimeOffset? ScheduledStartAt { get; init; }
public int? PlannedDurationMinutes { get; init; }
public string? PublicAnnouncement { get; init; }
public PodcastReplayResponse? Replay { get; init; }
}
/// <summary>An independently decodable MPEG-TS HLS segment.</summary>
public sealed record PodcastSegmentResponse(int Sequence, double DurationSeconds, int Bytes);

View file

@ -1,3 +1,7 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Abstract.Workflow;
public sealed class PerformerProfileSettings
@ -7,6 +11,9 @@ public sealed class PerformerProfileSettings
public string ExerciseCountryCode { get; set; } = "fr";
public string SIREN { get; set; } = string.Empty;
public string WebSite { get; set; } = string.Empty;
/// <summary>Owner-only PayPal recipient. Null preserves the existing setting; empty disables donations.</summary>
[MaxLength(127)]
public string? DonationPayPalEmail { get; set; }
public bool Active { get; set; }
public bool AcceptNotifications { get; set; }
public bool AcceptPublicContact { get; set; }

View file

@ -9,9 +9,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
</Project>

View file

@ -42,6 +42,11 @@ public interface IYavscApiClient : IAsyncDisposable
/// </summary>
HttpClient Http { get; }
/// <summary>Authenticated streaming request with token refresh and one 401 retry.
/// The caller owns the returned response; the factory must return a fresh request.</summary>
Task<HttpResponseMessage> SendAuthenticatedAsync(Func<HttpRequestMessage> requestFactory, CancellationToken ct = default)
=> throw new NotSupportedException("Authenticated streaming is not supported by this transport.");
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
@ -19,6 +21,13 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func<string> baseAddre
public Task<PodcastResponse> CreateAsync(string title, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute(Prefix), new CreatePodcastRequest { Title = title }, ct);
public Task<PodcastResponse> PrepareAsync(PreparePodcastRequest request, Guid? id = null, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(id.HasValue ? HttpMethod.Put : HttpMethod.Post,
Absolute(id.HasValue ? $"{Prefix}/{id}/planned" : $"{Prefix}/planned"), request, ct);
public Task<PodcastResponse> StartAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute($"{Prefix}/{id}/start"), ct: ct);
public Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastManifestResponse>(HttpMethod.Get, Absolute($"{Prefix}/{id}"), ct: ct);
@ -35,6 +44,36 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func<string> baseAddre
public Task<PodcastResponse> CompleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute($"{Prefix}/{id}/complete"), ct: ct);
public Task RetryReplayAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Post, Absolute($"{Prefix}/{id}/replay/retry"), ct: ct);
public async Task<byte[]> DownloadReplayRangeAsync(Guid id, long offset, int count, long size,
CancellationToken ct = default)
{
if (offset < 0 || count <= 0 || count > 256 * 1024 || offset >= size)
throw new ArgumentOutOfRangeException(nameof(offset), "Invalid replay byte range.");
var end = offset + Math.Min(count, size - offset) - 1;
using var response = await api.SendAuthenticatedAsync(() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, Absolute($"{Prefix}/{id}/replay.mp4"));
request.Headers.Range = new RangeHeaderValue(offset, end);
return request;
}, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var range = response.Content.Headers.ContentRange;
if (response.StatusCode != HttpStatusCode.PartialContent || range?.Unit != "bytes"
|| range.From != offset || range.To != end || range.Length != size)
throw new InvalidDataException("Le serveur n'a pas respecte la plage demandee pour le replay.");
var length = checked((int)(end - offset + 1));
var bytes = new byte[length];
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await stream.ReadExactlyAsync(bytes, ct).ConfigureAwait(false);
var extra = new byte[1];
if (await stream.ReadAsync(extra.AsMemory(), ct).ConfigureAwait(false) != 0)
throw new InvalidDataException("Reponse MP4 plus grande que la plage demandee.");
return bytes;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Delete, Absolute($"{Prefix}/{id}"), ct: ct);

View file

@ -15,10 +15,10 @@
</Description>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Middleware;
using Yavsc.Api.Services;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@ -16,13 +17,40 @@ namespace Yavsc.ApiControllers;
[Authorize(Policy = "ApiScope")]
[Route(Constants.APIPrefix + "/podcasts")]
[TypeFilter(typeof(PodcastExceptionFilter))]
public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
public sealed class PodcastController(ILiveProcessor processor, IPodcastScheduleService schedule) : ControllerBase
{
/// <summary>Lists live broadcasts and retained replays for signed-in listeners.</summary>
[HttpGet]
[ProducesResponseType<IReadOnlyList<PodcastResponse>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PodcastResponse>>> List(CancellationToken ct)
=> Ok(await processor.ListAsync(ct));
=> Ok(await schedule.ListAsync(User.GetUserId(), ct));
/// <summary>Public announcements contain presentation details only, never the stream.</summary>
[AllowAnonymous, HttpGet("announcements")]
[ProducesResponseType<IReadOnlyList<PodcastAnnouncementResponse>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Announcements(CancellationToken ct)
=> Ok(await schedule.AnnouncementsAsync(ct));
/// <summary>Prepares a private emission; a nonempty announcement publishes its presentation.</summary>
[HttpPost("planned")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status201Created)]
public async Task<IActionResult> Prepare(PreparePodcastRequest request, CancellationToken ct)
{
var response = await schedule.PrepareAsync(User.GetUserId(), request, null, ct);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
/// <summary>Edits an owned emission before its first start.</summary>
[HttpPut("{id:guid}/planned")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> Update(Guid id, PreparePodcastRequest request, CancellationToken ct)
=> Ok(await schedule.PrepareAsync(User.GetUserId(), request, id, ct));
/// <summary>Manually starts a prepared emission; retries retain the same broadcast identifier.</summary>
[HttpPost("{id:guid}/start")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> Start(Guid id, CancellationToken ct)
=> Ok(await schedule.StartAsync(id, User.GetUserId(), ct));
/// <summary>Starts a broadcast owned by the current user.</summary>
[HttpPost]
@ -41,7 +69,7 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
[ProducesResponseType<PodcastManifestResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PodcastManifestResponse>> Get(Guid id, CancellationToken ct)
=> Ok(await processor.GetAsync(id, ct));
=> Ok(await schedule.GetAsync(id, User.GetUserId(), ct));
/// <summary>Commits an MPEG-TS segment. Identical retries are idempotent.</summary>
[HttpPut("{id:guid}/segments/{sequence:int}")]
@ -55,17 +83,43 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct));
await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct);
return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
}
/// <summary>Ends the broadcast and publishes its retained HLS replay.</summary>
/// <summary>Ends the broadcast and schedules preparation of its MP4 replay.</summary>
[HttpPost("{id:guid}/complete")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<ActionResult<PodcastResponse>> Complete(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.CompleteAsync(id, ownerId, ct));
await processor.CompleteAsync(id, ownerId, ct);
return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
}
/// <summary>Serves a completed MP4 replay with authenticated byte-range support.</summary>
[HttpGet("{id:guid}/replay.mp4")]
[HttpHead("{id:guid}/replay.mp4")]
[Produces("video/mp4")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Replay(Guid id, CancellationToken ct)
{
Response.Headers.CacheControl = "private, no-store";
return File(await processor.OpenReplayAsync(id, ct), "video/mp4", enableRangeProcessing: true);
}
/// <summary>Retries failed MP4 preparation. Only the broadcast owner can retry.</summary>
[HttpPost("{id:guid}/replay/retry")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> RetryReplay(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
await processor.RetryReplayAsync(id, ownerId, ct);
return AcceptedAtAction(nameof(Get), new { id }, value: null);
}
/// <summary>Serves the authenticated event playlist, terminated for completed or interrupted replays.</summary>
@ -102,7 +156,7 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
await processor.DeleteAsync(id, ownerId, ct);
await schedule.DeleteAsync(id, ownerId, ct);
return NoContent();
}
}

View file

@ -39,11 +39,12 @@ namespace Yavsc.WebApi.Controllers
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITrueEmailSender _emailSender;
private readonly ICalendarManager _calendarManager;
private readonly CallSignalingService _calls;
public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> userManager,
ITrueEmailSender emailSender, ICalendarManager calendarManager)
ITrueEmailSender emailSender, ICalendarManager calendarManager, CallSignalingService calls)
{
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext;
@ -51,6 +52,7 @@ namespace Yavsc.WebApi.Controllers
_userManager = userManager;
_emailSender = emailSender;
_calendarManager = calendarManager;
_calls = calls;
}
[HttpGet("me")]
@ -86,7 +88,8 @@ namespace Yavsc.WebApi.Controllers
Avatar = userData.Avatar,
FullName = userData.FullName,
Address = userData.PostalAddress?.Address ?? string.Empty,
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar,
HideIpAddressInCalls = userData.HideIpAddressInCalls
};
user.BankInfoSummary = userData.BankInfo is { Count: > 0 }
? string.Join(" | ", userData.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
@ -151,6 +154,11 @@ namespace Yavsc.WebApi.Controllers
return BadRequest(new { error = "Payload is required." });
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (request.BankInfoSummary is not null)
{
return BadRequest(new { error = "Bank information must be managed through the bank-info endpoint." });
@ -172,6 +180,8 @@ namespace Yavsc.WebApi.Controllers
return NotFound();
}
var previousUserName = user.UserName;
var privacyActivated = request.HideIpAddressInCalls == true && !user.HideIpAddressInCalls;
if (!string.IsNullOrWhiteSpace(request.UserName))
{
var trimmedUserName = request.UserName.Trim();
@ -212,8 +222,16 @@ namespace Yavsc.WebApi.Controllers
user.DedicatedGoogleCalendar = request.GoogleCalendarId.Trim();
}
if (request.HideIpAddressInCalls is bool hideIpAddressInCalls)
{
user.HideIpAddressInCalls = hideIpAddressInCalls;
}
await _dbContext.SaveChangesAsync(uid);
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar });
if (previousUserName != null &&
(privacyActivated || previousUserName != user.UserName))
await _calls.EndForUserAsync(previousUserName, "profile-changed");
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar, hideIpAddressInCalls = user.HideIpAddressInCalls });
}
[HttpPut("preferences/monthly-email")]
@ -555,6 +573,7 @@ namespace Yavsc.WebApi.Controllers
}
[HttpGet("performer-profile")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> GetPerformerProfile()
{
var uid = User.GetUserId();
@ -580,6 +599,7 @@ namespace Yavsc.WebApi.Controllers
ExerciseCountryCode = profile?.ExerciseCountryCode ?? "fr",
SIREN = profile?.SIREN ?? string.Empty,
WebSite = profile?.WebSite ?? string.Empty,
DonationPayPalEmail = profile?.DonationPayPalEmail ?? string.Empty,
Active = profile?.Active ?? false,
AcceptNotifications = profile?.AcceptNotifications ?? false,
AcceptPublicContact = profile?.AcceptPublicContact ?? false,
@ -612,6 +632,20 @@ namespace Yavsc.WebApi.Controllers
return Forbid();
}
if (!ModelState.IsValid)
return ValidationProblem(ModelState);
if (model.DonationPayPalEmail is { } email)
{
model.DonationPayPalEmail = email.Trim();
if (model.DonationPayPalEmail.Length > 0
&& (model.DonationPayPalEmail.Length > 127
|| !new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(model.DonationPayPalEmail)))
{
ModelState.AddModelError(nameof(model.DonationPayPalEmail), "Une adresse e-mail PayPal valide est requise.");
return ValidationProblem(ModelState);
}
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
@ -637,7 +671,7 @@ namespace Yavsc.WebApi.Controllers
AcceptNotifications = model.AcceptNotifications,
AcceptPublicContact = model.AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
OrganizationAddress = new Yavsc.Models.Relationship.Location()
OrganizationAddress = new Yavsc.Models.Relationship.Location { Address = string.Empty }
};
_dbContext.Performers.Add(profile);
}
@ -652,6 +686,9 @@ namespace Yavsc.WebApi.Controllers
profile.UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients;
}
if (model.DonationPayPalEmail != null)
profile.DonationPayPalEmail = model.DonationPayPalEmail.Length == 0 ? null : model.DonationPayPalEmail;
var desiredCodes = (model.SelectedActivityCodes ?? [])
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)

View file

@ -2,6 +2,27 @@
@token = REPLACE_WITH_ACCESS_TOKEN
@id = REPLACE_WITH_PODCAST_ID
### Prepare a private emission (api scope; start/stop remain manual)
POST {{baseUrl}}/api/v1/podcasts/planned
Authorization: Bearer {{token}}
Content-Type: application/json
{"title":"Concert prepare","scheduledStartAt":"2026-12-01T20:00:00+01:00","plannedDurationMinutes":90}
### Edit before start and publish a public presentation; null/empty announcement unpublishes it
PUT {{baseUrl}}/api/v1/podcasts/{{id}}/planned
Authorization: Bearer {{token}}
Content-Type: application/json
{"title":"Concert prepare","scheduledStartAt":"2026-12-01T20:00:00+01:00","plannedDurationMinutes":90,"publicAnnouncement":"Venez ecouter notre concert"}
### Public presentation only (no token, no stream)
GET {{baseUrl}}/api/v1/podcasts/announcements
### Start the prepared emission manually, retaining its identifier (owner only)
POST {{baseUrl}}/api/v1/podcasts/{{id}}/start
Authorization: Bearer {{token}}
### Create a live broadcast
POST {{baseUrl}}/api/v1/podcasts
Authorization: Bearer {{token}}
@ -36,6 +57,19 @@ Authorization: Bearer {{token}}
POST {{baseUrl}}/api/v1/podcasts/{{id}}/complete
Authorization: Bearer {{token}}
### Ready MP4: authenticated seek range (206; 409 until replay.state is Ready)
GET {{baseUrl}}/api/v1/podcasts/{{id}}/replay.mp4
Authorization: Bearer {{token}}
Range: bytes=0-65535
### MP4 size and range support without downloading the body
HEAD {{baseUrl}}/api/v1/podcasts/{{id}}/replay.mp4
Authorization: Bearer {{token}}
### Retry failed MP4 preparation (owner only; 202)
POST {{baseUrl}}/api/v1/podcasts/{{id}}/replay/retry
Authorization: Bearer {{token}}
### Delete an ended podcast (owner only)
DELETE {{baseUrl}}/api/v1/podcasts/{{id}}
Authorization: Bearer {{token}}

View file

@ -87,6 +87,11 @@ internal class Program
.AddJsonProtocol(options =>
options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton<IConnexionManager, HubConnectionManager>();
services.Configure<AudioVideoCallOptions>(
builder.Configuration.GetSection(AudioVideoCallOptions.SectionName));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<CallSignalingService>();
services.AddHostedService(provider => provider.GetRequiredService<CallSignalingService>());
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>

View file

@ -0,0 +1,25 @@
using Yavsc.Services;
namespace Yavsc.Api.Services;
public sealed class PodcastReplayWorker(
ILiveProcessor processor, IPodcastReplayRemuxer remuxer, ILogger<PodcastReplayWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (await processor.PrepareNextReplayAsync(remuxer, stoppingToken)) continue;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
logger.LogError(ex, "Podcast MP4 worker failed; retrying the scan");
}
try { await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
}
}
}

View file

@ -0,0 +1,161 @@
#nullable enable
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Streaming;
using Yavsc.Models;
using Yavsc.Models.Streaming;
using Yavsc.Services;
namespace Yavsc.Api.Services;
public interface IPodcastScheduleService
{
Task<PodcastResponse> PrepareAsync(string ownerId, PreparePodcastRequest request, Guid? id, CancellationToken ct);
Task<PodcastResponse> StartAsync(Guid id, string ownerId, CancellationToken ct);
Task<IReadOnlyList<PodcastResponse>> ListAsync(string ownerId, CancellationToken ct);
Task<PodcastManifestResponse> GetAsync(Guid id, string ownerId, CancellationToken ct);
Task<IReadOnlyList<PodcastAnnouncementResponse>> AnnouncementsAsync(CancellationToken ct);
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
}
public sealed class PodcastScheduleService(ApplicationDbContext db, ILiveProcessor processor,
IOptions<PodcastOptions> options) : IPodcastScheduleService
{
// The media store is single-process; serialize database/media lifecycle changes on that same host.
private static readonly SemaphoreSlim Gate = new(1);
public async Task<PodcastResponse> PrepareAsync(string ownerId, PreparePodcastRequest request, Guid? id, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Trim().Length > 160
|| request.ScheduledStartAt == default || request.PlannedDurationMinutes < 1
|| request.PublicAnnouncement?.Length > 1023)
throw new PodcastException(400, "Titre, debut et duree positive en minutes requis.");
await Gate.WaitAsync(ct);
try
{
var flow = id.HasValue ? await OwnedAsync(id.Value, ownerId, ct) : null;
if (flow != null && (flow.StartedAt != null
|| (await processor.ListAsync(ct)).Any(p => p.Id == id)))
throw new PodcastException(409, "Une emission demarree ne peut plus etre reprogrammee.");
if (flow == null)
{
if (!await db.Users.AnyAsync(u => u.Id == ownerId, ct))
throw new PodcastException(404, "Compte introuvable.");
if (await db.LiveFlow.CountAsync(f => f.OwnerId == ownerId && f.PodcastId != null && !f.PodcastDeleted, ct)
>= options.Value.MaxOwnerPodcasts)
throw new PodcastException(413, "Nombre maximal d'emissions atteint. Supprimez une ancienne emission.");
flow = new LiveFlow
{
PodcastId = Guid.NewGuid(), OwnerId = ownerId, PreparedAt = DateTimeOffset.UtcNow,
Title = "", Pitch = "", MediaType = "application/vnd.apple.mpegurl", DifferedFileName = ""
};
db.LiveFlow.Add(flow);
}
flow.Title = request.Title.Trim();
flow.ScheduledStartAt = request.ScheduledStartAt.ToUniversalTime();
flow.PlannedDurationMinutes = request.PlannedDurationMinutes;
flow.PublicAnnouncement = request.PublicAnnouncement?.Trim() ?? "";
await db.SaveChangesAsync(ct);
await db.Entry(flow).Reference(f => f.Owner).LoadAsync(ct);
return Planned(flow);
}
finally { Gate.Release(); }
}
public async Task<PodcastResponse> StartAsync(Guid id, string ownerId, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
var flow = await OwnedAsync(id, ownerId, ct);
var existing = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (existing != null && existing.State != PodcastState.Live)
throw new PodcastException(409, "Cette emission est terminee. Preparez une nouvelle emission.");
if (flow.StartedAt != null && existing == null)
throw new PodcastException(404, "Les medias de cette emission sont introuvables.");
var podcast = await processor.CreateAsync(ownerId, flow.Owner.UserName ?? ownerId, flow.Title, ct, id);
flow.StartedAt ??= podcast.CreatedAt;
await db.SaveChangesAsync(ct);
return Enrich(podcast, flow);
}
finally { Gate.Release(); }
}
public async Task<IReadOnlyList<PodcastResponse>> ListAsync(string ownerId, CancellationToken ct)
{
var media = await processor.ListAsync(ct);
var flows = await db.LiveFlow.AsNoTracking().Include(f => f.Owner)
.Where(f => f.PodcastId != null).ToListAsync(ct);
var byId = flows.ToDictionary(f => f.PodcastId!.Value);
var result = media.Where(p => !byId.TryGetValue(p.Id, out var f) || !f.PodcastDeleted)
.Select(p => byId.TryGetValue(p.Id, out var f) ? Enrich(p, f) : p).ToList();
var mediaIds = media.Select(p => p.Id).ToHashSet();
result.AddRange(flows.Where(f => !f.PodcastDeleted && f.StartedAt == null && f.OwnerId == ownerId
&& !mediaIds.Contains(f.PodcastId!.Value)).Select(Planned));
return result.OrderByDescending(p => p.CreatedAt).ToArray();
}
public async Task<PodcastManifestResponse> GetAsync(Guid id, string ownerId, CancellationToken ct)
{
var flow = await db.LiveFlow.AsNoTracking().Include(f => f.Owner).SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null) return await processor.GetAsync(id, ct);
if (flow.PodcastDeleted) throw new PodcastException(404, "Podcast introuvable.");
var podcast = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (podcast != null)
{
var manifest = await processor.GetAsync(id, ct);
return manifest with { Podcast = Enrich(manifest.Podcast, flow) };
}
if (flow.OwnerId != ownerId || flow.StartedAt != null)
throw new PodcastException(404, "Podcast introuvable.");
return new(Planned(flow), []);
}
public async Task<IReadOnlyList<PodcastAnnouncementResponse>> AnnouncementsAsync(CancellationToken ct)
{
var flows = await db.LiveFlow.AsNoTracking().Include(f => f.Owner)
.Where(f => f.PodcastId != null && !f.PodcastDeleted && f.ScheduledStartAt != null
&& f.PlannedDurationMinutes != null && f.PublicAnnouncement != "").ToListAsync(ct);
return flows.OrderBy(f => f.ScheduledStartAt).Select(f => new PodcastAnnouncementResponse(
f.PodcastId!.Value, f.Title, f.Owner.UserName ?? f.OwnerId, f.ScheduledStartAt!.Value,
f.PlannedDurationMinutes!.Value, f.PublicAnnouncement)).ToArray();
}
public async Task DeleteAsync(Guid id, string ownerId, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
var flow = await db.LiveFlow.SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null) { await processor.DeleteAsync(id, ownerId, ct); return; }
if (flow.OwnerId != ownerId) throw new PodcastException(403, "Seul l'auteur peut supprimer ce podcast.");
var podcast = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (podcast?.State == PodcastState.Live)
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
if (podcast != null) await processor.DeleteAsync(id, ownerId, ct);
flow.PodcastDeleted = true;
await db.SaveChangesAsync(ct);
}
finally { Gate.Release(); }
}
private async Task<LiveFlow> OwnedAsync(Guid id, string ownerId, CancellationToken ct)
{
var flow = await db.LiveFlow.Include(f => f.Owner).SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null || flow.PodcastDeleted) throw new PodcastException(404, "Emission introuvable.");
if (flow.OwnerId != ownerId) throw new PodcastException(403, "Seul l'auteur peut modifier cette emission.");
return flow;
}
private static PodcastResponse Planned(LiveFlow flow) => Enrich(new(
flow.PodcastId!.Value, flow.Title, flow.OwnerId, flow.Owner.UserName ?? flow.OwnerId,
flow.PreparedAt!.Value, flow.PreparedAt.Value, PodcastState.Planned, 0, 0, 0), flow);
private static PodcastResponse Enrich(PodcastResponse podcast, LiveFlow flow) => podcast with
{
FlowId = flow.Id, ScheduledStartAt = flow.ScheduledStartAt,
PlannedDurationMinutes = flow.PlannedDurationMinutes,
PublicAnnouncement = string.IsNullOrEmpty(flow.PublicAnnouncement) ? null : flow.PublicAnnouncement
};
}

View file

@ -8,6 +8,9 @@ public static class PodcastServiceCollectionExtensions
{
services.Configure<PodcastOptions>(configuration.GetSection("Podcast"));
services.AddSingleton<ILiveProcessor, LiveProcessor>();
services.AddScoped<IPodcastScheduleService, PodcastScheduleService>();
services.AddSingleton<IPodcastReplayRemuxer, FfmpegPodcastReplayRemuxer>();
services.AddHostedService<PodcastReplayWorker>();
services.AddOpenApi("podcasts", options => options.ShouldInclude = description => description.GroupName == "podcasts");
return services;
}

View file

@ -5,10 +5,10 @@
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Api</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -6,10 +6,10 @@
<RootNamespace>Yavsc.Blogs</RootNamespace>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.8.0</AssemblyVersion>
<FileVersion>1.0.8.0</FileVersion>
<InformationalVersion>1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8</InformationalVersion>
<Version>1.0.8-458</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd</InformationalVersion>
<Version>1.1.0-feat-calls.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -664,8 +664,13 @@ namespace Yavsc.Controllers
if (_dbContext.Performers.Any(p=>p.PerformerId == uid))
{
_dbContext.Update(model);
_dbContext.Entry(model).Property(p => p.DonationPayPalEmail).IsModified = false;
}
else
{
model.DonationPayPalEmail = null;
_dbContext.Performers.Add(model);
}
else _dbContext.Performers.Add(model);
_dbContext.SaveChanges(User.GetUserId());
// Give this user the Performer role
if (!User.IsInMsRole("Performer"))
@ -716,6 +721,8 @@ namespace Yavsc.Controllers
bool existing = _dbContext.Performers.Any(x => x.PerformerId == uid);
if (existing)
{
if (await _dbContext.PerformerDonations.AnyAsync(d => d.PerformerId == uid))
return Conflict("Ce profil a recu des dons et doit etre conserve pour leur suivi. Desactivez les nouveaux dons dans les parametres PayPal.");
_dbContext.Performers.Remove(
_dbContext.Performers.First(x => x.PerformerId == uid)
);

View file

@ -0,0 +1,232 @@
#nullable enable
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Models;
using Yavsc.Models.Payment;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.Podcast;
using Yavsc.Web.Services;
namespace Yavsc.Controllers;
[Authorize(Policy = "WebUser")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class DonationController(ApplicationDbContext db, IPayPalDonationGateway paypal,
IOptions<SiteSettings> siteOptions, ILogger<DonationController> logger) : Controller
{
[HttpGet]
public async Task<IActionResult> Index(CancellationToken ct)
=> View(await db.PerformerDonations.AsNoTracking().Where(d => d.DonorId == User.GetUserId())
.OrderByDescending(d => d.CreatedAt)
.Join(db.Users, d => d.PerformerId, u => u.Id,
(d, u) => new DonationDetailsViewModel(d, u.UserName ?? d.PerformerId)).ToArrayAsync(ct));
[HttpGet]
public async Task<IActionResult> Settings(CancellationToken ct)
{
var performer = await db.Performers.FindAsync([User.GetUserId()], ct);
if (performer == null)
{
Response.StatusCode = StatusCodes.Status404NotFound;
ViewData["PaymentError"] = "Creez d'abord votre profil performer dans Mon compte > Parametres professionnels pour recevoir des dons.";
return View("Error");
}
return View(new DonationSettingsViewModel { PayPalEmail = performer.DonationPayPalEmail });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Settings(DonationSettingsViewModel model, CancellationToken ct)
{
var performer = await db.Performers.FindAsync([User.GetUserId()], ct);
if (performer == null) return NotFound();
if (!ModelState.IsValid) return View(model);
performer.DonationPayPalEmail = string.IsNullOrWhiteSpace(model.PayPalEmail) ? null : model.PayPalEmail.Trim();
await db.SaveChangesAsync(ct);
TempData["DonationSettingsSaved"] = true;
return RedirectToAction(nameof(Settings));
}
[HttpGet]
public async Task<IActionResult> Create(string id, CancellationToken ct)
{
var performer = await RecipientAsync(id, ct);
if (performer == null) return NotFound();
return View(new CreateDonationViewModel { PerformerId = id, PerformerName = performer.Performer.UserName ?? id });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateDonationViewModel model, CancellationToken ct)
{
var performer = await RecipientAsync(model.PerformerId, ct);
if (performer == null) return NotFound();
model.PerformerName = performer.Performer.UserName ?? performer.PerformerId;
if (decimal.Round(model.Amount, 2) != model.Amount)
ModelState.AddModelError(nameof(model.Amount), "Le montant doit avoir au maximum deux decimales.");
if (!ModelState.IsValid) return View(model);
var urls = PaymentUrls();
var donation = new PerformerDonation
{
Id = Guid.NewGuid(), DonorId = User.GetUserId(), PerformerId = performer.PerformerId,
RecipientEmail = performer.DonationPayPalEmail!, Amount = model.Amount, CreatedAt = DateTimeOffset.UtcNow
};
db.PerformerDonations.Add(donation);
await db.SaveChangesAsync(ct);
donation.CheckoutToken = await paypal.CreateAsync(donation,
urls.Return + donation.Id, urls.Cancel + donation.Id, urls.Notify, ct);
donation.State = PerformerDonationState.AwaitingApproval;
await SaveAsync(donation, ct);
return Redirect(paypal.CheckoutUrl(donation.CheckoutToken));
}
[HttpGet]
public async Task<IActionResult> Return(Guid id, string token, CancellationToken ct)
{
var donation = await OwnedAsync(id, ct);
if (donation == null || string.IsNullOrWhiteSpace(token) || donation.CheckoutToken != token) return NotFound();
if (donation.State == PerformerDonationState.AwaitingApproval)
await paypal.ApproveAsync(donation, ct);
return RedirectToAction(nameof(Details), new { id });
}
[HttpGet]
public async Task<IActionResult> Details(Guid id, CancellationToken ct)
{
var donation = await OwnedAsync(id, ct);
if (donation == null) return NotFound();
var name = await db.Users.Where(u => u.Id == donation.PerformerId).Select(u => u.UserName).SingleAsync(ct);
return View(new DonationDetailsViewModel(donation, name ?? donation.PerformerId));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
var donation = await OwnedAsync(id, ct);
if (donation == null) return NotFound();
if (donation.State == PerformerDonationState.Completed)
return RedirectToAction(nameof(Details), new { id });
if (donation.State != PerformerDonationState.AwaitingApproval)
throw new DonationPaymentException(409, "Ce paiement est deja traite ou en cours de verification. Ne creez pas un second don.");
var approval = await paypal.ApproveAsync(donation, ct);
donation.State = PerformerDonationState.Processing;
await SaveAsync(donation, ct);
var receipt = await paypal.CaptureAsync(donation, approval.PayerId, ct);
// An IPN may have confirmed the payment while the capture request was in flight.
await db.Entry(donation).ReloadAsync(ct);
if (donation.TransactionId != null && donation.TransactionId != receipt.TransactionId)
throw new DonationPaymentException(409, "La reference PayPal ne correspond pas au don.");
if (donation.State is PerformerDonationState.Processing or PerformerDonationState.Pending)
{
donation.TransactionId = receipt.TransactionId;
donation.State = receipt.Completed ? PerformerDonationState.Completed : PerformerDonationState.Pending;
donation.CompletedAt = receipt.Completed ? DateTimeOffset.UtcNow : null;
await SaveAsync(donation, ct);
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpGet]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
var donation = await OwnedAsync(id, ct);
if (donation == null) return NotFound();
return View(donation);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> CancelConfirm(Guid id, CancellationToken ct)
{
var donation = await OwnedAsync(id, ct);
if (donation == null) return NotFound();
if (donation.State == PerformerDonationState.AwaitingApproval)
{
donation.State = PerformerDonationState.Cancelled;
await SaveAsync(donation, ct);
}
return RedirectToAction(nameof(Details), new { id });
}
[AllowAnonymous, HttpPost, IgnoreAntiforgeryToken, RawPayPalNotification, RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> Notify(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync(ct);
var fields = await paypal.VerifyNotificationAsync(body, ct);
if (!Guid.TryParseExact(fields.GetValueOrDefault("invoice"), "N", out var id))
throw new DonationPaymentException(400, "Reference de don PayPal invalide.");
var donation = await db.PerformerDonations.FindAsync([id], ct);
if (donation == null) return NotFound();
var transactionId = fields.GetValueOrDefault("txn_id");
if (fields.GetValueOrDefault("txn_type") != "express_checkout"
|| !string.Equals(fields.GetValueOrDefault("receiver_email"), donation.RecipientEmail, StringComparison.OrdinalIgnoreCase)
|| !PayPalDonationGateway.SameAmount(fields.GetValueOrDefault("mc_gross"), donation.Amount)
|| fields.GetValueOrDefault("mc_currency") != donation.Currency
|| string.IsNullOrWhiteSpace(transactionId) || transactionId.Length > 32
|| (donation.TransactionId != null && donation.TransactionId != transactionId)
|| donation.State is PerformerDonationState.Created or PerformerDonationState.AwaitingApproval or PerformerDonationState.Cancelled)
throw new DonationPaymentException(409, "La notification PayPal ne correspond pas au don.");
var paymentState = fields.GetValueOrDefault("payment_status");
if (paymentState is not ("Completed" or "Pending" or "Denied" or "Failed" or "Expired" or "Voided"))
{
logger.LogWarning("PayPal donation {DonationId} notification has unsupported status {Status}", id, paymentState);
return Ok();
}
if (donation.State != PerformerDonationState.Completed)
{
donation.TransactionId = transactionId;
donation.State = paymentState switch
{
"Completed" => PerformerDonationState.Completed,
"Pending" => PerformerDonationState.Pending,
_ => PerformerDonationState.Failed
};
donation.CompletedAt = paymentState == "Completed" ? DateTimeOffset.UtcNow : null;
await SaveAsync(donation, ct);
}
return Ok();
}
private Task<PerformerProfile?> RecipientAsync(string id, CancellationToken ct)
=> db.Performers.Include(p => p.Performer).SingleOrDefaultAsync(p => p.PerformerId == id
&& p.Active && p.DonationPayPalEmail != null && p.DonationPayPalEmail != "", ct);
private Task<PerformerDonation?> OwnedAsync(Guid id, CancellationToken ct)
=> db.PerformerDonations.SingleOrDefaultAsync(d => d.Id == id && d.DonorId == User.GetUserId(), ct);
private async Task SaveAsync(PerformerDonation donation, CancellationToken ct)
{
donation.Revision = Guid.NewGuid();
await db.SaveChangesAsync(ct);
}
private (string Return, string Cancel, string Notify) PaymentUrls()
{
if (!Uri.TryCreate(siteOptions.Value.ExternalUrl, UriKind.Absolute, out var origin)
|| origin.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(origin.UserInfo))
throw new DonationPaymentException(503, "L'adresse HTTPS du site n'est pas configuree.");
var root = siteOptions.Value.ExternalUrl.TrimEnd('/') + "/";
return (root + "Donation/Return?id=", root + "Donation/Cancel?id=", root + "Donation/Notify");
}
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var executed = await next();
var error = executed.Exception;
if (error == null || error is not (DonationPaymentException or DbUpdateConcurrencyException) || Response.HasStarted)
return;
logger.LogError(error, "Donation request failed for {Action}", context.ActionDescriptor.DisplayName);
executed.ExceptionHandled = true;
Response.StatusCode = error is DonationPaymentException payment ? payment.StatusCode : 409;
ViewData["PaymentError"] = error is DonationPaymentException known
? known.Message : "Ce don a ete modifie par une autre requete. Consultez son etat avant de recommencer.";
if (context.ActionArguments.TryGetValue("id", out var id) && id is Guid donationId)
ViewData["DonationId"] = donationId;
executed.Result = View("Error");
}
}

View file

@ -0,0 +1,113 @@
#nullable enable
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Streaming;
using Yavsc.Services;
using Yavsc.Models;
using Yavsc.ViewModels.Podcast;
using Yavsc.Web.Services;
namespace Yavsc.Controllers;
[Authorize(Policy = "WebUser")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class PodcastController(
PodcastWebClient api,
ApplicationDbContext db,
ILogger<PodcastController> logger) : Controller
{
[AllowAnonymous, HttpGet]
public async Task<IActionResult> Announcements(CancellationToken ct)
{
using var response = await api.SendAsync(HttpMethod.Get, "announcements", null, ct, publicAnnouncement: true);
var announcements = await response.Content.ReadFromJsonAsync<List<PodcastAnnouncementResponse>>(ct)
?? throw new JsonException("Empty podcast announcements response.");
return View(announcements);
}
[HttpGet]
public async Task<IActionResult> Index(CancellationToken ct)
{
var podcasts = await api.GetAsync<List<PodcastResponse>>("", ct);
var ownerIds = podcasts.Select(p => p.OwnerId).Distinct().ToArray();
var performers = await db.Performers.AsNoTracking()
.Where(p => ownerIds.Contains(p.PerformerId) && p.Active && p.DonationPayPalEmail != null
&& p.DonationPayPalEmail != "")
.Select(p => p.PerformerId).ToArrayAsync(ct);
return View(new PodcastCatalogueViewModel(
podcasts.OrderByDescending(p => p.CreatedAt).ToArray(), performers.ToHashSet(StringComparer.Ordinal)));
}
[HttpGet("Podcast/{id:guid}/index.m3u8")]
public async Task<IActionResult> Playlist(Guid id, CancellationToken ct)
{
var manifest = await api.GetAsync<PodcastManifestResponse>(id.ToString(), ct);
var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:15\n")
.Append("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:EVENT\n");
foreach (var segment in manifest.Segments)
{
text.Append(CultureInfo.InvariantCulture, $"#EXTINF:{segment.DurationSeconds:0.######},\n");
text.Append(Url.Action(nameof(Segment), new { id, sequence = segment.Sequence })).Append('\n');
}
if (manifest.Podcast.State != PodcastState.Live) text.Append("#EXT-X-ENDLIST\n");
return Content(text.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
}
[HttpGet("Podcast/{id:guid}/segments/{sequence:int:min(0)}.ts")]
public Task<IActionResult> Segment(Guid id, int sequence, CancellationToken ct)
=> ProxyAsync($"{id}/segments/{sequence}.ts", "video/mp2t", ct);
[HttpGet("Podcast/{id:guid}/replay.mp4")]
[HttpHead("Podcast/{id:guid}/replay.mp4")]
public Task<IActionResult> Replay(Guid id, CancellationToken ct)
=> ProxyAsync($"{id}/replay.mp4", "video/mp4", ct);
private async Task<IActionResult> ProxyAsync(string path, string contentType, CancellationToken ct)
{
RangeHeaderValue? range = null;
if (Request.Headers.TryGetValue("Range", out var value)
&& !RangeHeaderValue.TryParse(value.ToString(), out range))
return BadRequest(new ProblemDetails { Status = 400, Title = "Plage de lecture invalide." });
using var response = await api.SendAsync(HttpMethods.IsHead(Request.Method)
? HttpMethod.Head : HttpMethod.Get, path, range, ct);
Response.StatusCode = (int)response.StatusCode;
Response.ContentType = contentType;
Response.Headers.CacheControl = "private, no-store";
Response.ContentLength = response.Content.Headers.ContentLength;
if (response.Content.Headers.ContentRange != null)
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
if (response.Headers.AcceptRanges.Count > 0)
Response.Headers.AcceptRanges = string.Join(", ", response.Headers.AcceptRanges);
if (!HttpMethods.IsHead(Request.Method))
await response.Content.CopyToAsync(Response.Body, ct);
return new EmptyResult();
}
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var executed = await next();
var error = executed.Exception;
if (error == null || Response.HasStarted || HttpContext.RequestAborted.IsCancellationRequested)
return;
if (error is not (PodcastException or HttpRequestException or JsonException or TaskCanceledException))
return;
logger.LogError(error, "Podcast web request failed for {Action}", context.ActionDescriptor.DisplayName);
var status = error is PodcastException podcastError ? podcastError.StatusCode : 502;
var message = error is PodcastException known
? known.Message : "Le service de podcasts est indisponible. Reessayez plus tard.";
Response.StatusCode = status;
executed.ExceptionHandled = true;
executed.Result = context.ActionDescriptor.RouteValues["action"] == nameof(Index)
? View("Index", new PodcastCatalogueViewModel([], new HashSet<string>(), message))
: StatusCode(status, new ProblemDetails { Status = status, Title = message });
}
}

View file

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Yavsc.Controllers;
[AttributeUsage(AttributeTargets.Method)]
public sealed class RawPayPalNotificationAttribute : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
context.ValueProviderFactories.RemoveType<FormValueProviderFactory>();
context.ValueProviderFactories.RemoveType<JQueryFormValueProviderFactory>();
context.ValueProviderFactories.RemoveType<FormFileValueProviderFactory>();
}
public void OnResourceExecuted(ResourceExecutedContext context) { }
}

View file

@ -92,6 +92,10 @@ public static class HostingExtensions
// Add the system clock service
_ = services.AddSingleton<IConnexionManager, HubConnectionManager>();
_ = services.AddSingleton<ILiveProcessor, LiveProcessor>();
services.AddHttpClient<Yavsc.Web.Services.PodcastWebClient>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
services.AddHttpClient<Yavsc.Web.Services.IPayPalDonationGateway, Yavsc.Web.Services.PayPalDonationGateway>()
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
_ = services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
AddIdentityDBAndStores(builder)
@ -278,6 +282,8 @@ public static class HostingExtensions
// options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
options.AddPolicy("WebUser", new AuthorizationPolicyBuilder(IdentityConstants.ApplicationScheme)
.RequireAuthenticatedUser().Build());
options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission()));
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class HideIpAddressInCalls : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "HideIpAddressInCalls",
table: "AspNetUsers",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HideIpAddressInCalls",
table: "AspNetUsers");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class PerformerPayPalDonations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DonationPayPalEmail",
table: "Performers",
type: "character varying(127)",
maxLength: 127,
nullable: true);
migrationBuilder.CreateTable(
name: "PerformerDonations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DonorId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
PerformerId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
RecipientEmail = table.Column<string>(type: "character varying(127)", maxLength: 127, nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
State = table.Column<int>(type: "integer", nullable: false),
CheckoutToken = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
TransactionId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Revision = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PerformerDonations", x => x.Id);
table.ForeignKey(
name: "FK_PerformerDonations_AspNetUsers_DonorId",
column: x => x.DonorId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PerformerDonations_Performers_PerformerId",
column: x => x.PerformerId,
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_PerformerDonations_CheckoutToken",
table: "PerformerDonations",
column: "CheckoutToken",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PerformerDonations_DonorId",
table: "PerformerDonations",
column: "DonorId");
migrationBuilder.CreateIndex(
name: "IX_PerformerDonations_PerformerId",
table: "PerformerDonations",
column: "PerformerId");
migrationBuilder.CreateIndex(
name: "IX_PerformerDonations_TransactionId",
table: "PerformerDonations",
column: "TransactionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PerformerDonations");
migrationBuilder.DropColumn(
name: "DonationPayPalEmail",
table: "Performers");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class PodcastScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlannedDurationMinutes",
table: "LiveFlow",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "PodcastDeleted",
table: "LiveFlow",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "PodcastId",
table: "LiveFlow",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "PreparedAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PublicAnnouncement",
table: "LiveFlow",
type: "character varying(1023)",
maxLength: 1023,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ScheduledStartAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "StartedAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_LiveFlow_PodcastId",
table: "LiveFlow",
column: "PodcastId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_LiveFlow_PodcastId",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PlannedDurationMinutes",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PodcastDeleted",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PodcastId",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PreparedAt",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PublicAnnouncement",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "ScheduledStartAt",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "StartedAt",
table: "LiveFlow");
}
}
}

View file

@ -1170,6 +1170,9 @@ namespace Yavsc.Migrations
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<bool>("HideIpAddressInCalls")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
@ -2735,6 +2738,71 @@ namespace Yavsc.Migrations
b.ToTable("PayPalPayment");
});
modelBuilder.Entity("Yavsc.Models.Payment.PerformerDonation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric(18,2)");
b.Property<string>("CheckoutToken")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<string>("DonorId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("PerformerId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("RecipientEmail")
.IsRequired()
.HasMaxLength(127)
.HasColumnType("character varying(127)");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<int>("State")
.HasColumnType("integer");
b.Property<string>("TransactionId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("CheckoutToken")
.IsUnique();
b.HasIndex("DonorId");
b.HasIndex("PerformerId");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("PerformerDonations");
});
modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b =>
{
b.Property<long>("Id")
@ -3014,9 +3082,31 @@ namespace Yavsc.Migrations
b.Property<string>("Pitch")
.HasColumnType("text");
b.Property<int?>("PlannedDurationMinutes")
.HasColumnType("integer");
b.Property<bool>("PodcastDeleted")
.HasColumnType("boolean");
b.Property<Guid?>("PodcastId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PreparedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PublicAnnouncement")
.HasMaxLength(1023)
.HasColumnType("character varying(1023)");
b.Property<DateTimeOffset?>("ScheduledStartAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SequenceNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.HasColumnType("text");
@ -3024,6 +3114,9 @@ namespace Yavsc.Migrations
b.HasIndex("OwnerId");
b.HasIndex("PodcastId")
.IsUnique();
b.ToTable("LiveFlow");
});
@ -3263,6 +3356,10 @@ namespace Yavsc.Migrations
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("DonationPayPalEmail")
.HasMaxLength(127)
.HasColumnType("character varying(127)");
b.Property<string>("ExerciseCountryCode")
.IsRequired()
.HasColumnType("text");
@ -4511,6 +4608,21 @@ namespace Yavsc.Migrations
b.Navigation("Executor");
});
modelBuilder.Entity("Yavsc.Models.Payment.PerformerDonation", b =>
{
b.HasOne("Yavsc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("DonorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", null)
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Yavsc.Models.Relationship.Circle", b =>
{
b.HasOne("Yavsc.Models.ApplicationUser", null)

View file

@ -0,0 +1,174 @@
#nullable enable
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using Yavsc.Models.Payment;
namespace Yavsc.Web.Services;
public sealed class DonationPaymentException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
public sealed record DonationApproval(string PayerId);
public sealed record DonationReceipt(string TransactionId, bool Completed);
public interface IPayPalDonationGateway
{
Task<string> CreateAsync(PerformerDonation donation, string returnUrl, string cancelUrl, string notifyUrl, CancellationToken ct);
Task<DonationApproval> ApproveAsync(PerformerDonation donation, CancellationToken ct);
Task<DonationReceipt> CaptureAsync(PerformerDonation donation, string payerId, CancellationToken ct);
Task<IReadOnlyDictionary<string, string>> VerifyNotificationAsync(string body, CancellationToken ct);
string CheckoutUrl(string token);
}
public sealed class PayPalDonationGateway(HttpClient http, IOptions<PayPalSettings> options) : IPayPalDonationGateway
{
private PayPalSettings Settings
{
get
{
var settings = options.Value;
if (settings.Mode is not ("production" or "sandbox"))
throw new DonationPaymentException(503, "Le paiement PayPal n'est pas configure.");
return settings;
}
}
public string CheckoutUrl(string token)
=> $"https://{(Settings.Mode == "production" ? "www" : "www.sandbox")}.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token={Uri.EscapeDataString(token)}";
public async Task<string> CreateAsync(PerformerDonation donation, string returnUrl, string cancelUrl,
string notifyUrl, CancellationToken ct)
{
var fields = PaymentFields(donation);
fields["RETURNURL"] = returnUrl;
fields["CANCELURL"] = cancelUrl;
fields["PAYMENTREQUEST_0_NOTIFYURL"] = notifyUrl;
fields["PAYMENTREQUEST_0_DESC"] = "Don au performer sur Yavsc";
fields["NOSHIPPING"] = "1";
var result = await CallAsync("SetExpressCheckout", fields, ct);
var token = Required(result, "TOKEN");
return token.Length <= 100 ? token : throw new DonationPaymentException(502, "Jeton PayPal invalide.");
}
public async Task<DonationApproval> ApproveAsync(PerformerDonation donation, CancellationToken ct)
{
var fields = await CallAsync("GetExpressCheckoutDetails", new()
{
["TOKEN"] = donation.CheckoutToken ?? throw new InvalidOperationException("Missing donation checkout token.")
}, ct);
if (Required(fields, "TOKEN") != donation.CheckoutToken
|| Required(fields, "PAYMENTREQUEST_0_INVNUM") != donation.Id.ToString("N")
|| Required(fields, "PAYMENTREQUEST_0_CURRENCYCODE") != donation.Currency
|| !SameAmount(fields.GetValueOrDefault("PAYMENTREQUEST_0_AMT"), donation.Amount)
|| !string.Equals(Required(fields, "PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"),
donation.RecipientEmail, StringComparison.OrdinalIgnoreCase))
throw new DonationPaymentException(409, "La confirmation PayPal ne correspond pas a ce don.");
return new DonationApproval(Required(fields, "PAYERID"));
}
public async Task<DonationReceipt> CaptureAsync(PerformerDonation donation, string payerId, CancellationToken ct)
{
var fields = PaymentFields(donation);
fields["TOKEN"] = donation.CheckoutToken!;
fields["PAYERID"] = payerId;
fields["MSGSUBID"] = donation.Id.ToString("N");
var result = await CallAsync("DoExpressCheckoutPayment", fields, ct);
if (!SameAmount(result.GetValueOrDefault("PAYMENTINFO_0_AMT"), donation.Amount)
|| Required(result, "PAYMENTINFO_0_CURRENCYCODE") != donation.Currency
|| !string.Equals(Required(result, "PAYMENTINFO_0_SELLERPAYPALACCOUNTID"),
donation.RecipientEmail, StringComparison.OrdinalIgnoreCase)
|| Required(result, "PAYMENTINFO_0_PAYMENTSTATUS") is not ("Completed" or "Pending"))
throw new DonationPaymentException(409, "Le paiement PayPal n'est pas confirme pour ce don.");
var transactionId = Required(result, "PAYMENTINFO_0_TRANSACTIONID");
if (transactionId.Length > 32)
throw new DonationPaymentException(502, "Reference de transaction PayPal invalide.");
return new DonationReceipt(transactionId,
result["PAYMENTINFO_0_PAYMENTSTATUS"] == "Completed");
}
public async Task<IReadOnlyDictionary<string, string>> VerifyNotificationAsync(string body, CancellationToken ct)
{
var endpoint = Settings.Mode == "production"
? "https://ipnpb.paypal.com/cgi-bin/webscr" : "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr";
using var content = new StringContent("cmd=_notify-validate&" + body, Encoding.UTF8, "application/x-www-form-urlencoded");
using var response = await PostAsync(endpoint, content, ct);
if ((await response.Content.ReadAsStringAsync(ct)).Trim() != "VERIFIED")
throw new DonationPaymentException(400, "Notification PayPal non verifiee.");
return Parse(body);
}
public static bool SameAmount(string? value, decimal amount)
=> decimal.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var actual)
&& actual == amount;
private static Dictionary<string, string> PaymentFields(PerformerDonation donation) => new()
{
["PAYMENTREQUEST_0_PAYMENTACTION"] = "Sale",
["PAYMENTREQUEST_0_AMT"] = donation.Amount.ToString("F2", CultureInfo.InvariantCulture),
["PAYMENTREQUEST_0_CURRENCYCODE"] = donation.Currency,
["PAYMENTREQUEST_0_INVNUM"] = donation.Id.ToString("N"),
["PAYMENTREQUEST_0_PAYMENTREQUESTID"] = donation.Id.ToString("N"),
["PAYMENTREQUEST_0_SELLERPAYPALACCOUNTID"] = donation.RecipientEmail
};
private async Task<Dictionary<string, string>> CallAsync(string method, Dictionary<string, string> fields, CancellationToken ct)
{
var settings = Settings;
var accounts = settings.Accounts?.Where(a => a.ApiUsername == settings.MerchantAccountUserName).ToArray();
if (accounts is not { Length: 1 } || string.IsNullOrWhiteSpace(accounts[0].ApiUsername)
|| string.IsNullOrWhiteSpace(accounts[0].ApiPassword) || string.IsNullOrWhiteSpace(accounts[0].Signature))
throw new DonationPaymentException(503, "Le compte marchand PayPal n'est pas configure.");
fields["METHOD"] = method;
fields["VERSION"] = "124.0";
fields["USER"] = accounts[0].ApiUsername;
fields["PWD"] = accounts[0].ApiPassword;
fields["SIGNATURE"] = accounts[0].Signature;
using var content = new FormUrlEncodedContent(fields);
using var response = await PostAsync(settings.Mode == "production"
? "https://api-3t.paypal.com/nvp" : "https://api-3t.sandbox.paypal.com/nvp", content, ct);
var result = Parse(await response.Content.ReadAsStringAsync(ct));
if (result.GetValueOrDefault("ACK") is not ("Success" or "SuccessWithWarning"))
throw new DonationPaymentException(502, "PayPal a refuse cette operation. Aucun don n'est marque comme paye.");
return result;
}
private async Task<HttpResponseMessage> PostAsync(string endpoint, HttpContent content, CancellationToken ct)
{
try
{
var response = await http.PostAsync(endpoint, content, ct);
if (response.IsSuccessStatusCode) return response;
response.Dispose();
throw new DonationPaymentException(502, "Le service PayPal est indisponible.");
}
catch (HttpRequestException ex)
{
throw new DonationPaymentException(502, $"La connexion PayPal a echoue ({ex.HttpRequestError}). Verifiez l'etat du don avant de recommencer.");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new DonationPaymentException(504, "La reponse PayPal a expire. Verifiez l'etat du don avant de recommencer.");
}
}
private static Dictionary<string, string> Parse(string body)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in body.Split('&'))
{
var parts = pair.Split('=', 2);
if (parts.Length != 2 || !result.TryAdd(Decode(parts[0]), Decode(parts[1])))
throw new DonationPaymentException(502, "Reponse PayPal invalide.");
}
return result;
}
private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " "));
private static string Required(IReadOnlyDictionary<string, string> fields, string name)
=> fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
? value : throw new DonationPaymentException(502, "Reponse PayPal incomplete.");
}

View file

@ -0,0 +1,79 @@
#nullable enable
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Security.Claims;
using IdentityServer8;
using Microsoft.Extensions.Options;
using Yavsc.Services;
using Yavsc.Server.Helpers;
namespace Yavsc.Web.Services;
public sealed class PodcastWebClient(HttpClient http, IOptions<SiteSettings> options,
IdentityServerTools authority, IHttpContextAccessor contextAccessor)
{
private string? _accessToken;
public async Task<T> GetAsync<T>(string path, CancellationToken ct)
{
using var response = await SendAsync(HttpMethod.Get, path, null, ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new JsonException("Empty podcast API response.");
}
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path,
RangeHeaderValue? range, CancellationToken ct, bool publicAnnouncement = false)
{
var settings = options.Value;
if (!Uri.TryCreate(settings.ApiUrl, UriKind.Absolute, out var address)
|| address.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(address.UserInfo))
throw new PodcastException(503, "La connexion aux podcasts n'est pas configuree.");
if (publicAnnouncement && (method != HttpMethod.Get || path != "announcements"))
throw new InvalidOperationException("Only podcast announcements may be requested anonymously.");
if (!publicAnnouncement && _accessToken == null)
{
var user = contextAccessor.HttpContext?.User;
var userId = user?.GetUserId();
if (user?.Identity?.IsAuthenticated != true || string.IsNullOrWhiteSpace(userId))
throw new PodcastException(401, "Une connexion est requise pour lire les podcasts.");
_accessToken = await authority.IssueJwtAsync(300, new List<Claim>
{
new("sub", userId),
new("name", user.Identity.Name ?? userId),
new("scope", "api"),
new("aud", "api")
});
if (string.IsNullOrWhiteSpace(_accessToken))
throw new InvalidOperationException("Yavsc.Org did not issue a podcast access token.");
}
address = new Uri(settings.ApiUrl.TrimEnd('/') + "/");
if (!address.AbsolutePath.EndsWith("/" + Constants.APIPrefix + "/", StringComparison.Ordinal))
address = new Uri(address, Constants.APIPrefix + "/");
using var request = new HttpRequestMessage(method, new Uri(address, "podcasts/" + path));
if (!publicAnnouncement)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
request.Headers.Range = range;
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable)
{
var status = response.StatusCode;
response.Dispose();
throw new PodcastException(status switch
{
HttpStatusCode.NotFound => 404,
HttpStatusCode.Conflict => 409,
_ => 502
}, status switch
{
HttpStatusCode.NotFound => "Podcast introuvable.",
HttpStatusCode.Conflict => "Ce replay n'est pas encore disponible.",
_ => "Le service de podcasts est indisponible."
});
}
return response;
}
}

View file

@ -0,0 +1,26 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Yavsc.Models.Payment;
namespace Yavsc.ViewModels.Podcast;
public sealed class CreateDonationViewModel
{
[Required]
public string PerformerId { get; set; } = "";
[BindNever]
public string PerformerName { get; set; } = "";
[Range(typeof(decimal), "0.01", "9999999999999999.99",
ParseLimitsInInvariantCulture = true, ConvertValueInInvariantCulture = true)]
public decimal Amount { get; set; } = 5m;
}
public sealed class DonationSettingsViewModel
{
[EmailAddress, StringLength(127)]
public string? PayPalEmail { get; set; }
}
public sealed record DonationDetailsViewModel(PerformerDonation Donation, string PerformerName);

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