diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41b544604..224008f7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,27 +1,6 @@
# 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
diff --git a/Dockerfile b/Dockerfile
index 9d6687b2c..8ebbb73f0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -93,7 +93,6 @@ 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 \
diff --git a/INSTALL.md b/INSTALL.md
index 09dba0d5e..5cea2b6f0 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -81,15 +81,8 @@ 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 client dependency.
+default PulseAudio microphone. FFmpeg is **not** a runtime 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.
@@ -100,11 +93,7 @@ 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 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:
+`Podcast__RootPath`). No database migration or server-side encoder is needed:
```json
{
@@ -114,9 +103,7 @@ not video/audio re-encoding:
"MaxOwnerBytes": 1073741824,
"MaxSegments": 7200,
"MaxOwnerPodcasts": 100,
- "IdleTimeoutSeconds": 60,
- "FfmpegPath": "ffmpeg",
- "ReplayTimeoutSeconds": 600
+ "IdleTimeoutSeconds": 60
}
}
```
@@ -133,10 +120,7 @@ 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, 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.
+and forward Authorization on every API request.
A broadcast without a new committed segment for 60 seconds, or present
when the API restarts, becomes an interrupted replay on the next catalogue,
@@ -150,26 +134,9 @@ for manual recovery. There is no automatic resumption after an application
restart; these private local recordings may be removed manually when no
longer needed.
-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
+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
camera/microphone permissions, drivers and codecs must still be verified
on each target Windows/Linux machine.
diff --git a/README.md b/README.md
index 90e7ba5ba..6d4784bad 100644
--- a/README.md
+++ b/README.md
@@ -38,8 +38,7 @@ Pour une prise en main guidee avec agents IA:
## Construction
```bash
-sudo dotnet workload restore
-dotnet build
+ dotnet build
```
et, pour execution en environment de développement
diff --git a/doc/Architecture.md b/doc/Architecture.md
index 17df96891..d48dff22b 100644
--- a/doc/Architecture.md
+++ b/doc/Architecture.md
@@ -57,139 +57,6 @@ 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`
diff --git a/doc/README.md b/doc/README.md
index 73f4bae6c..10e32b0c9 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -16,7 +16,6 @@ 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é |
diff --git a/doc/architecture/audio-video-calls.md b/doc/architecture/audio-video-calls.md
deleted file mode 100644
index 4c4ee0f26..000000000
--- a/doc/architecture/audio-video-calls.md
+++ /dev/null
@@ -1,561 +0,0 @@
-# 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.
\ No newline at end of file
diff --git a/doc/testing.md b/doc/testing.md
index 5b41ddba3..7b5de174f 100644
--- a/doc/testing.md
+++ b/doc/testing.md
@@ -78,62 +78,24 @@ 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 '*PodcastReplayTests' --filter-class '*PodcastScheduleTests' \
- --filter-class '*PerformerDonationSettingsTests'
+ --filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests'
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
- --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'
+ --filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests'
```
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 MPEG-TS. FFmpeg n'est pas une dependance
-d'execution de PostIt (le serveur MP4, lui, en a besoin).
+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).
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
@@ -160,9 +122,6 @@ 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.
diff --git a/dotnet-tools.json b/dotnet-tools.json
index 037b83194..1762a89de 100644
--- a/dotnet-tools.json
+++ b/dotnet-tools.json
@@ -8,20 +8,6 @@
"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
}
}
}
\ No newline at end of file
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 4f3edf355..7884f601d 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -48,10 +48,6 @@ 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',
diff --git a/package.json b/package.json
index c8d376253..e419cd0ca 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,6 @@
"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"
},
@@ -14,7 +13,6 @@
"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",
diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs
index 5b7b30def..2648b0f94 100644
--- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs
+++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs
@@ -21,14 +21,9 @@ internal static class PlatformBootstrap
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
- Platform.CreateVideoSurface = (player, replay) => new AndroidVideoSurface(player, replay);
+ Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
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;
diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj
index 6cbde4739..5b7f4f968 100644
--- a/src/PostIt/PostIt.Android/PostIt.Android.csproj
+++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj
@@ -9,10 +9,10 @@
1.0
apk
false
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
@@ -32,7 +32,6 @@
-
net10.0
enable
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
app.manifest
diff --git a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
index fc733e3f4..50e64f713 100644
--- a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
+++ b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
@@ -180,60 +180,6 @@ 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(),
- 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;
@@ -242,7 +188,6 @@ public class AdministrationAccessTests
public Task? 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)
@@ -271,7 +216,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], HideIpAddressInCalls = HideIpAddressInCalls };
+ : new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
}
else if (path.Contains("/resource-usage/"))
{
diff --git a/src/PostIt/PostIt.Tests/AndroidCameraFrameRotationTestActivity.cs b/src/PostIt/PostIt.Tests/AndroidCameraFrameRotationTestActivity.cs
index 04f814a95..2de366408 100644
--- a/src/PostIt/PostIt.Tests/AndroidCameraFrameRotationTestActivity.cs
+++ b/src/PostIt/PostIt.Tests/AndroidCameraFrameRotationTestActivity.cs
@@ -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\npreview=RGBA,detach,reattach,letterbox\nsource=synthetic");
+ File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\nsource=synthetic");
}
catch (Exception ex)
{
@@ -60,10 +60,6 @@ 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");
@@ -75,8 +71,6 @@ 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;
@@ -89,10 +83,6 @@ 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);
@@ -117,24 +107,11 @@ 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
{
@@ -166,33 +143,5 @@ 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
diff --git a/src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs b/src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs
deleted file mode 100644
index 204736434..000000000
--- a/src/PostIt/PostIt.Tests/AndroidLanCallTestActivity.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-#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;
-
-/// Debug-only physical camera/SRTP test; no account, API or media recording.
-[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 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("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
diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs
index d07cf7a30..d8e340878 100644
--- a/src/PostIt/PostIt.Tests/BlogReaderTests.cs
+++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs
@@ -82,29 +82,6 @@ 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()
{
diff --git a/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs b/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
deleted file mode 100644
index c3f7945cb..000000000
--- a/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-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(() => client.InviteAsync("General", "bob", ct));
- await Assert.ThrowsAsync(() => client.InviteLanAsync("General", "bob", ct));
- await Assert.ThrowsAsync(() => client.AcceptAsync(id, ct));
- await Assert.ThrowsAsync(() => client.EndAsync(id, ct));
- await Assert.ThrowsAsync(() => 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(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken));
- Assert.Equal(HubConnectionState.Disconnected, connection.State);
- using var replacement = new CallSignalingClient(connection);
- await Assert.ThrowsAsync(() => 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(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(JsonSerializer.Serialize(signal, options), options));
- var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp };
- Assert.Equal(lan, JsonSerializer.Deserialize(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(JsonSerializer.Serialize(endpoint, options), options));
- }
-}
diff --git a/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs b/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
deleted file mode 100644
index 6afe892a2..000000000
--- a/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
+++ /dev/null
@@ -1,243 +0,0 @@
-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 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 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? Invitation;
- public event Action? Accepted;
- public event Action? Ended;
- public event Action? Signal;
- public event Action? Interrupted;
- public CallSession Call { get; } = Session();
- public List Sent { get; } = [];
- public List EndedCalls { get; } = [];
- public int Invites { get; private set; }
- public Task 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();
- }
-}
diff --git a/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs b/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
deleted file mode 100644
index f473dc40c..000000000
--- a/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-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(() => 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);
- }
- }
-}
diff --git a/src/PostIt/PostIt.Tests/PerformerDonationSettingsTests.cs b/src/PostIt/PostIt.Tests/PerformerDonationSettingsTests.cs
deleted file mode 100644
index 3078e401d..000000000
--- a/src/PostIt/PostIt.Tests/PerformerDonationSettingsTests.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-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("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 CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
- {
- if (typeof(T) == typeof(List))
- return Task.FromResult((T)(object)new List());
- Assert.EndsWith("/account/performer-profile", path);
- if (method == HttpMethod.Put) Saved = Assert.IsType(body);
- return Task.FromResult((T)(object)new PerformerProfileSettings
- {
- PerformerId = "author", DonationPayPalEmail = "initial@payments.example"
- });
- }
- }
-}
diff --git a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
index 36bf33d4e..b15fca406 100644
--- a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
+++ b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs
@@ -1,10 +1,7 @@
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;
@@ -12,70 +9,6 @@ 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(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("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()
{
@@ -85,7 +18,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(tokenPath + ".settings"), store);
+ await using var api = new YavscApiClient(new Settings(), store);
await using var transport = new Transport();
try
{
@@ -120,26 +53,9 @@ 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(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()
{
@@ -204,12 +120,9 @@ 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, Control? preview = null)
+ public FakeCapture(string directory)
{
- 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");
@@ -223,7 +136,7 @@ public sealed class PodcastBroadcasterTests
_completion.TrySetResult();
return Task.CompletedTask;
}
- public ValueTask DisposeAsync() { Disposed = true; OnDispose?.Invoke(); return ValueTask.CompletedTask; }
+ public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
}
private sealed class Transport : IYavscApiClient
@@ -235,14 +148,10 @@ public sealed class PodcastBroadcasterTests
public List 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 CallAsync(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);
}
diff --git a/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
index 2719fecfd..8fc0c7cca 100644
--- a/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
+++ b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs
@@ -1,8 +1,5 @@
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;
@@ -10,47 +7,28 @@ using Yavsc.Api.Client;
namespace PostIt.Tests;
-public sealed class PodcastNativeCaptureTests(ITestOutputHelper output)
+public sealed class PodcastNativeCaptureTests
{
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
- [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)
+ [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()
{
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("--vout=dummy", "--aout=dummy", "--no-xlib",
- "--avcodec-hw=none", "--avcodec-threads=2");
+ using var vlc = new LibVLC();
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
using var player = new MediaPlayer(vlc);
- 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);
+ PodcastBroadcaster.ConfigureOutput(media, root);
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);
@@ -63,34 +41,8 @@ public sealed class PodcastNativeCaptureTests(ITestOutputHelper output)
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))]
diff --git a/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs b/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs
deleted file mode 100644
index ff822e492..000000000
--- a/src/PostIt/PostIt.Tests/PodcastPreferencesTests.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-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"); }
- }
-}
diff --git a/src/PostIt/PostIt.Tests/PodcastReplayTests.cs b/src/PostIt/PostIt.Tests/PodcastReplayTests.cs
deleted file mode 100644
index 7242bd6a6..000000000
--- a/src/PostIt/PostIt.Tests/PodcastReplayTests.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-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("PositionSlider"));
- Assert.NotNull(overlay.FindControl
diff --git a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs
index b8560eaeb..f3ffaa096 100644
--- a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs
+++ b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs
@@ -17,38 +17,6 @@ namespace PostIt.Tests;
///
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();
- 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()
{
diff --git a/src/PostIt/PostIt/Controls/ReplayControls.axaml b/src/PostIt/PostIt/Controls/ReplayControls.axaml
deleted file mode 100644
index e850d789d..000000000
--- a/src/PostIt/PostIt/Controls/ReplayControls.axaml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Controls/ReplayControls.axaml.cs b/src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
deleted file mode 100644
index aedd20383..000000000
--- a/src/PostIt/PostIt/Controls/ReplayControls.axaml.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-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();
- }
-}
diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj
index e84006554..9d4c7e963 100644
--- a/src/PostIt/PostIt/PostIt.csproj
+++ b/src/PostIt/PostIt/PostIt.csproj
@@ -3,10 +3,10 @@
net10.0
enable
latest
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
true
diff --git a/src/PostIt/PostIt/Services/CallSignalingClient.cs b/src/PostIt/PostIt/Services/CallSignalingClient.cs
deleted file mode 100644
index 7ad171d3b..000000000
--- a/src/PostIt/PostIt/Services/CallSignalingClient.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-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? Invitation;
- public event Action? Accepted;
- public event Action? Ended;
- public event Action? Signal;
- public event Action? Interrupted;
-
- public CallSignalingClient(HubConnection connection)
- {
- _connection = connection;
- _subscriptions =
- [
- connection.On(CallHubMethods.Invitation, call => Invitation?.Invoke(call)),
- connection.On(CallHubMethods.Accepted, call => Accepted?.Invoke(call)),
- connection.On(CallHubMethods.Ended, call => Ended?.Invoke(call)),
- connection.On(CallHubMethods.SignalReceived,
- (id, signal) => Signal?.Invoke(id, signal))
- ];
- connection.Reconnecting += OnInterruptedAsync;
- connection.Closed += OnInterruptedAsync;
- }
-
- public Task InviteAsync(string room, string user, CancellationToken ct = default)
- {
- EnsureConnected();
- return _connection.InvokeAsync(CallHubMethods.Invite, room, user, ct);
- }
-
- public Task InviteLanAsync(string room, string user, CancellationToken ct = default)
- {
- EnsureConnected();
- return _connection.InvokeAsync(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();
- }
-}
diff --git a/src/PostIt/PostIt/Services/ILanCallMedia.cs b/src/PostIt/PostIt/Services/ILanCallMedia.cs
deleted file mode 100644
index 30ff98959..000000000
--- a/src/PostIt/PostIt/Services/ILanCallMedia.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-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);
-}
diff --git a/src/PostIt/PostIt/Services/ILanCallSignaling.cs b/src/PostIt/PostIt/Services/ILanCallSignaling.cs
deleted file mode 100644
index 9d592299d..000000000
--- a/src/PostIt/PostIt/Services/ILanCallSignaling.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Yavsc.Abstract.Chat.Calls;
-
-namespace PostIt.Services;
-
-public interface ILanCallSignaling
-{
- event Action? Invitation;
- event Action? Accepted;
- event Action? Ended;
- event Action? Signal;
- event Action? Interrupted;
- Task 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);
-}
diff --git a/src/PostIt/PostIt/Services/IPodcastCapture.cs b/src/PostIt/PostIt/Services/IPodcastCapture.cs
index ca0577f63..5640a15c2 100644
--- a/src/PostIt/PostIt/Services/IPodcastCapture.cs
+++ b/src/PostIt/PostIt/Services/IPodcastCapture.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
-using Avalonia.Controls;
namespace PostIt.Services;
@@ -9,10 +8,9 @@ public sealed record CaptureDevice(string Name, string Value);
public sealed record PodcastCaptureDevices(
IReadOnlyList Cameras, IReadOnlyList Microphones);
-/// A running camera/microphone encoder with an optional native preview.
+/// A running encoder publishing closed segments and an atomic index.m3u8.
public interface IPodcastCapture : IAsyncDisposable
{
Task Completion { get; }
- Control? Preview => null;
Task StopAsync();
}
diff --git a/src/PostIt/PostIt/Services/LibVlcLanCallMedia.cs b/src/PostIt/PostIt/Services/LibVlcLanCallMedia.cs
deleted file mode 100644
index da7488a46..000000000
--- a/src/PostIt/PostIt/Services/LibVlcLanCallMedia.cs
+++ /dev/null
@@ -1,197 +0,0 @@
-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 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(); }
- }
-}
diff --git a/src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs b/src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
index 03e252d57..2d90cae3c 100644
--- a/src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
+++ b/src/PostIt/PostIt/Services/LibVlcPodcastCapture.cs
@@ -1,8 +1,6 @@
using System;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
-using Avalonia.Controls;
-using Avalonia.Threading;
namespace PostIt.Services;
@@ -13,37 +11,24 @@ 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 createMedia, string directory, bool preview = false)
+ public LibVlcPodcastCapture(Func createMedia, string directory)
{
Core.Initialize();
- // 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();
+ _vlc = CameraStreamer.CreatePreferredLibVlc();
try
{
_media = createMedia(_vlc);
- PodcastBroadcaster.ConfigureOutput(_media, directory, preview);
+ PodcastBroadcaster.ConfigureOutput(_media, directory);
_player = new MediaPlayer(_vlc);
- if (preview)
- {
- _preview = new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
- _preview.LayoutUpdated += OnPreviewLayout;
- }
_player.EncounteredError += OnError;
- if (!preview) StartPlayer();
+ if (!_player.Play(_media))
+ throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC.");
}
catch
{
- if (_preview is not null)
- {
- _preview.LayoutUpdated -= OnPreviewLayout;
- _preview.MediaPlayer = null;
- }
_player?.Dispose();
_media?.Dispose();
_vlc.Dispose();
@@ -51,53 +36,14 @@ 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 (_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;
- }
+ if (_stopped) return;
+ _stopped = true;
await Task.Run(_player.Stop);
_completion.TrySetResult();
}
@@ -107,8 +53,6 @@ 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();
diff --git a/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs b/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs
deleted file mode 100644
index 5a7974034..000000000
--- a/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-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();
- }
- }
- }
-}
diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs
index 77bf4c691..85a5026f5 100644
--- a/src/PostIt/PostIt/Services/Platform.cs
+++ b/src/PostIt/PostIt/Services/Platform.cs
@@ -12,14 +12,12 @@ namespace PostIt.Services;
///
public static class Platform
{
- public static Func? GetLanCallDevices { get; set; }
- public static Func? CreateLanCallMedia { get; set; }
public static Func? GetPodcastCaptureDevices { get; set; }
public static Func? RequestPodcastPermissionsAsync { get; set; }
public static Func>? CreatePodcastCaptureAsync { get; set; }
/// Platform-specific video surface inside the shared video page.
- public static Func? CreateVideoSurface { get; set; }
+ public static Func? CreateVideoSurface { get; set; }
public static event Action? PlaybackSuspending;
public static event Action? PlaybackResuming;
diff --git a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs
index 2094abc99..d59705a73 100644
--- a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs
+++ b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs
@@ -8,7 +8,6 @@ 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;
@@ -25,7 +24,6 @@ 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? Progress;
@@ -38,24 +36,18 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
_directory = directory;
}
- public static void ConfigureOutput(Media media, string directory, bool preview = false)
+ public static void ConfigureOutput(Media media, string directory)
{
var path = directory.Replace('\\', '/').Replace("\"", "\\\"");
- var recording = "std{"
+ 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{"
+ $"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\"}}";
- 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));
+ + $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}");
}
public static async Task StartAsync(PodcastApiClient api, string title,
- string camera, string? microphone, CancellationToken ct, Guid? plannedId = null)
- => await StartAsync(api, title,
- (directory, _) => Task.FromResult(new LibVlcPodcastCapture(
- vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct, plannedId);
+ string camera, string? microphone, CancellationToken ct)
+ => await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct);
internal static async Task StartAsync(PodcastApiClient api, string title,
Func createMedia, CancellationToken ct)
@@ -63,7 +55,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
(directory, _) => Task.FromResult(new LibVlcPodcastCapture(createMedia, directory)), ct);
public static async Task StartAsync(PodcastApiClient api, string title,
- Func> createCapture, CancellationToken ct, Guid? plannedId = null)
+ Func> createCapture, CancellationToken ct)
{
IPodcastCapture? capture = null;
PodcastBroadcaster? broadcaster = null;
@@ -73,9 +65,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
try
{
Directory.CreateDirectory(directory);
- 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.");
+ podcast = await api.CreateAsync(title, ct);
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
ct.ThrowIfCancellationRequested();
capture = await createCapture(directory, ct);
diff --git a/src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs b/src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
deleted file mode 100644
index c29806a90..000000000
--- a/src/PostIt/PostIt/Services/PodcastReplayMediaInput.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-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? 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);
- }
-}
diff --git a/src/PostIt/PostIt/Services/ReplayControlsState.cs b/src/PostIt/PostIt/Services/ReplayControlsState.cs
deleted file mode 100644
index 2cfa88923..000000000
--- a/src/PostIt/PostIt/Services/ReplayControlsState.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-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}";
- }
-}
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
index 1a747abff..c19b61f20 100644
--- a/src/PostIt/PostIt/Services/YavscApiClient.cs
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -519,14 +519,6 @@ 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 SendAuthenticatedAsync(
- Func requestFactory, CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@@ -535,8 +527,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var sendOnce = async () =>
{
- using var request = requestFactory();
- return await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ using var request = new HttpRequestMessage(method, path);
+ return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
@@ -548,15 +540,10 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
response = await sendOnce().ConfigureAwait(false);
}
- try
+ using (response)
{
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
- return response;
- }
- catch
- {
- response.Dispose();
- throw;
+ return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
}
diff --git a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
index 80f6de51a..e8fc1e535 100644
--- a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
@@ -1,28 +1,17 @@
using System;
-using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
-public partial class VideoPageViewModel(Uri source, string mimeType, Uri? externalUri = null) : ViewModelBase
+public sealed class VideoPageViewModel(Uri source, string mimeType) : 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; set; }
-
- public bool CanOpenExternal => CanLaunchExternally(ExternalUri);
+ public PodcastResponse? Podcast { get; init; }
+ public bool CanOpenExternal => Podcast is null;
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);
}
diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
index 50dacd192..7d5b516d6 100644
--- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
@@ -37,14 +37,6 @@ 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";
@@ -89,7 +81,6 @@ 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)
@@ -139,15 +130,6 @@ 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();
@@ -160,9 +142,7 @@ namespace PostIt.ViewModels.Chat
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
- Status = new StatusNotice(callError == null ? "Déconnecté" :
- "Déconnecté ; erreur de fermeture de l'appel : " + callError.Message,
- callError == null ? StatusSeverity.Info : StatusSeverity.Error,
+ Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
@@ -170,18 +150,6 @@ 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()));
@@ -220,15 +188,6 @@ 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("notifyUser", (type, user, msg) =>
{
diff --git a/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs
deleted file mode 100644
index 5753e7b36..000000000
--- a/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs
+++ /dev/null
@@ -1,332 +0,0 @@
-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 _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 Addresses { get; } = [];
- public ObservableCollection Cameras { get; } = [];
- public ObservableCollection 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? 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 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 action) =>
- Dispatcher.UIThread.Post(async () => await RunAsync(action));
-
- private async Task RunAsync(Func 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(); }
- }
-}
diff --git a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs
index 61f071683..449648ef3 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs
@@ -91,9 +91,6 @@ 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; }
@@ -198,7 +195,6 @@ 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;
@@ -284,8 +280,7 @@ public partial class UserProfilePageViewModel : ViewModelBase
UserName = NormalizeField(UserName, "—"),
FullName = NormalizeField(FullName, "—"),
Address = NormalizeField(Address, "—"),
- GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré"),
- HideIpAddressInCalls = HideIpAddressInCalls
+ GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré")
};
}
diff --git a/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs
index 0083a38db..91b6d0572 100644
--- a/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs
@@ -50,9 +50,6 @@ 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; }
@@ -104,7 +101,6 @@ 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;
@@ -146,7 +142,6 @@ public partial class PerformerConfigurationPageViewModel : ViewModelBase
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
- DonationPayPalEmail = DonationPayPalEmail.Trim(),
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,
diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
index 109fcb242..a963fea0d 100644
--- a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs
@@ -1,11 +1,9 @@
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;
@@ -32,9 +30,6 @@ 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 Podcasts { get; } = new();
public ObservableCollection Cameras { get; } = new();
@@ -43,31 +38,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
|| (!OperatingSystem.IsAndroid() && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()));
- public string Title
- {
- get => _title;
- set
- {
- if (!SetProperty(ref _title, value)) return;
- UpdateCommands();
- if (!_restoringPreferences)
- {
- _api.Settings.PodcastDefaultTitle = value;
- SavePreferences();
- }
- }
- }
+ public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
- [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
@@ -85,64 +57,28 @@ public partial class PodcastsPageViewModel : ViewModelBase
public CaptureDevice? SelectedCamera
{
get => _camera;
- set
- {
- if (!SetProperty(ref _camera, value)) return;
- UpdateCommands();
- if (!_restoringPreferences && value != null)
- {
- _api.Settings.PodcastCameraId = value.Value;
- SavePreferences();
- }
- }
+ set { if (SetProperty(ref _camera, value)) UpdateCommands(); }
}
public CaptureDevice? SelectedMicrophone
{
get => _microphone;
- set
- {
- if (!SetProperty(ref _microphone, value)) return;
- UpdateCommands();
- if (!_restoringPreferences && value != null)
- {
- _api.Settings.PodcastMicrophoneId = value.Value;
- SavePreferences();
- }
- }
+ set { if (SetProperty(ref _microphone, value)) UpdateCommands(); }
}
public PodcastResponse? SelectedPodcast
{
get => _selected;
- 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();
- }
+ set { if (SetProperty(ref _selected, value)) 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 { } }
@@ -155,54 +91,15 @@ 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)
- && (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);
+ && SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
FinishCommand = new AsyncRelayCommand(FinishAsync,
- () => SelectedPodcast is { State: PodcastState.Live or PodcastState.Interrupted } p
+ () => SelectedPodcast is { State: not PodcastState.Completed } 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()
@@ -210,14 +107,10 @@ 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));
}
@@ -237,34 +130,6 @@ 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)
@@ -283,7 +148,6 @@ public partial class PodcastsPageViewModel : ViewModelBase
private void DetectDevices()
{
- _restoringPreferences = true;
try
{
Cameras.Clear();
@@ -303,16 +167,11 @@ public partial class PodcastsPageViewModel : ViewModelBase
Microphones.Add(new("Sans microphone", ""));
foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value));
}
- 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;
+ SelectedCamera = Cameras.Count == 0 ? null : Cameras[0];
+ SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0];
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()
@@ -327,10 +186,9 @@ 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, SelectedPodcast?.Id)
- : await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token, SelectedPodcast?.Id);
+ (directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
+ : await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
_broadcaster.Progress += OnProgress;
- CapturePreview = _broadcaster.Preview;
_session.LogoutCompleted += OnLogout;
IsBroadcasting = true;
Status = "Capture en cours ; attente du premier segment HLS.";
@@ -362,7 +220,6 @@ public partial class PodcastsPageViewModel : ViewModelBase
}
finally
{
- CapturePreview = null;
broadcaster.Progress -= OnProgress;
_session.LogoutCompleted -= OnLogout;
try { await broadcaster.DisposeAsync(); }
@@ -396,8 +253,7 @@ 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");
- var catalogueUrl = new Uri(new Uri(_api.Settings.Authentication.Authority.TrimEnd('/') + "/"), "Podcast");
- await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl", catalogueUrl)
+ await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl")
{
PodcastClient = _client, Podcast = podcast
});
@@ -431,17 +287,4 @@ 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; }
- }
}
diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index 49707c036..72eea5d35 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -10,7 +10,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
using PostIt.Models;
-using PostIt.Services;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@@ -18,10 +17,6 @@ namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
- public Settings() { }
-
- internal Settings(string settingsFileFullName) => SettingsFileFullName = settingsFileFullName;
-
[JsonIgnore]
public string? SettingsFileFullName { get; private set; }
@@ -46,17 +41,6 @@ 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.");
@@ -151,11 +135,6 @@ 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();
///
/// Authentication can be reassigned wholesale by
@@ -413,11 +392,6 @@ 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();
diff --git a/src/PostIt/PostIt/Views/Video/VideoPage.axaml b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/Video/VideoPage.axaml
rename to src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
diff --git a/src/PostIt/PostIt/Views/Video/VideoPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
similarity index 62%
rename from src/PostIt/PostIt/Views/Video/VideoPage.axaml.cs
rename to src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
index 4fad48681..bcbceee76 100644
--- a/src/PostIt/PostIt/Views/Video/VideoPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
@@ -1,6 +1,4 @@
using System;
-using System.Threading;
-using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -17,8 +15,6 @@ public partial class VideoPage : ContentPage
private LibVLC? _libVlc;
private MediaPlayer? _player;
private PodcastMediaInput? _podcastInput;
- private PodcastReplayMediaInput? _replayInput;
- private CancellationTokenSource? _initialization;
public VideoPage() => InitializeComponent();
@@ -30,62 +26,28 @@ public partial class VideoPage : ContentPage
InitializePlayer();
}
- private async void InitializePlayer()
+ private 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 (!replay && podcast is { } retained && vm.PodcastClient is { } client)
+ if (vm.Podcast is { } podcast && vm.PodcastClient is { } client)
{
- _podcastInput = new PodcastMediaInput(client, retained.Id, retained.State == PodcastState.Live);
+ _podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live);
_podcastInput.Failed += PodcastFailed;
}
- using var media = _replayInput is not null ? new Media(_libVlc, _replayInput)
- : _podcastInput is not null ? new Media(_libVlc, _podcastInput)
- : new Media(_libVlc, vm.Source);
+ using var media = _podcastInput is null
+ ? new Media(_libVlc, vm.Source)
+ : new Media(_libVlc, _podcastInput);
if (_podcastInput is not null)
media.AddOption(":demux=ts");
- var surface = Platform.CreateVideoSurface?.Invoke(_player, replay)
- ?? new LibVLCSharp.Avalonia.VideoView
- {
- MediaPlayer = _player,
- Content = replay ? new PostIt.Controls.ReplayControls(_player) : null
- };
+ var surface = Platform.CreateVideoSurface?.Invoke(_player)
+ ?? new LibVLCSharp.Avalonia.VideoView { MediaPlayer = _player };
VideoSurface.Content = surface;
var player = _player;
// Navigation transforms do not change Bounds, so refresh the native surface after layout.
@@ -95,16 +57,14 @@ public partial class VideoPage : ContentPage
desktopVideo.TryUpdateNativeControlPosition();
}, DispatcherPriority.Loaded);
PlayButton.IsEnabled = true;
- PlaybackStatus.Text = replay ? "video/mp4" : vm.MimeType;
+ PlaybackStatus.Text = 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 (OperationCanceledException) when (ct.IsCancellationRequested) { }
- catch (Exception ex)
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException or VLCException)
{
- if (!ReferenceEquals(_initialization, cancellation)) return;
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}";
}
@@ -128,12 +88,8 @@ 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)
@@ -154,12 +110,6 @@ public partial class VideoPage : ContentPage
}
_libVlc?.Dispose();
_libVlc = null;
- if (_replayInput is not null)
- {
- _replayInput.Failed -= PodcastFailed;
- _replayInput.Dispose();
- _replayInput = null;
- }
}
private void SuspendPlayback()
@@ -172,6 +122,15 @@ 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(() =>
{
@@ -199,15 +158,15 @@ public partial class VideoPage : ContentPage
return;
try
{
- if (!vm.CanOpenExternal)
+ if (!CanLaunchExternally(vm.Source))
{
- PlaybackStatus.Text = "Cette ressource ne peut pas être ouverte dans le navigateur.";
+ PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert 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.ExternalUri))
+ if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
}
catch (Exception ex)
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
index d9e513f97..4749736a7 100644
--- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
@@ -10,14 +10,13 @@
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"
>
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
index 7f2ca053c..f73e1f827 100644
--- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
@@ -1,6 +1,4 @@
-using Avalonia;
using Avalonia.Controls;
-using PostIt.ViewModels.Chat;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
@@ -10,10 +8,4 @@ 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();
- }
}
diff --git a/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml b/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml
index f8d02b32a..ff9e0fe31 100644
--- a/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml
@@ -27,16 +27,6 @@
-
-
-
-
-
-
-
-
-
-
-
HttpClient Http { get; }
- /// Authenticated streaming request with token refresh and one 401 retry.
- /// The caller owns the returned response; the factory must return a fresh request.
- Task SendAuthenticatedAsync(Func requestFactory, CancellationToken ct = default)
- => throw new NotSupportedException("Authenticated streaming is not supported by this transport.");
-
/// Read a public JSON endpoint without attaching session credentials.
Task GetAnonymousAsync(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
diff --git a/src/Yavsc.Api.Client/PodcastApiClient.cs b/src/Yavsc.Api.Client/PodcastApiClient.cs
index 505d6dc07..52a7240f4 100644
--- a/src/Yavsc.Api.Client/PodcastApiClient.cs
+++ b/src/Yavsc.Api.Client/PodcastApiClient.cs
@@ -1,8 +1,6 @@
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;
@@ -21,13 +19,6 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func baseAddre
public Task CreateAsync(string title, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Post, Absolute(Prefix), new CreatePodcastRequest { Title = title }, ct);
- public Task PrepareAsync(PreparePodcastRequest request, Guid? id = null, CancellationToken ct = default)
- => api.CallAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post,
- Absolute(id.HasValue ? $"{Prefix}/{id}/planned" : $"{Prefix}/planned"), request, ct);
-
- public Task StartAsync(Guid id, CancellationToken ct = default)
- => api.CallAsync(HttpMethod.Post, Absolute($"{Prefix}/{id}/start"), ct: ct);
-
public Task GetAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Get, Absolute($"{Prefix}/{id}"), ct: ct);
@@ -44,36 +35,6 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func baseAddre
public Task CompleteAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync(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 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);
diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
index 377d7f79c..d310fbb2c 100644
--- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
+++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
@@ -15,10 +15,10 @@
https://github.com/pazof/yavsc
true
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
diff --git a/src/Yavsc.Api/Controllers/Musical/PodcastController.cs b/src/Yavsc.Api/Controllers/Musical/PodcastController.cs
index 3cd2822a9..bf2775c1b 100644
--- a/src/Yavsc.Api/Controllers/Musical/PodcastController.cs
+++ b/src/Yavsc.Api/Controllers/Musical/PodcastController.cs
@@ -5,7 +5,6 @@ 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;
@@ -17,40 +16,13 @@ namespace Yavsc.ApiControllers;
[Authorize(Policy = "ApiScope")]
[Route(Constants.APIPrefix + "/podcasts")]
[TypeFilter(typeof(PodcastExceptionFilter))]
-public sealed class PodcastController(ILiveProcessor processor, IPodcastScheduleService schedule) : ControllerBase
+public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
/// Lists live broadcasts and retained replays for signed-in listeners.
[HttpGet]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> List(CancellationToken ct)
- => Ok(await schedule.ListAsync(User.GetUserId(), ct));
-
- /// Public announcements contain presentation details only, never the stream.
- [AllowAnonymous, HttpGet("announcements")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task Announcements(CancellationToken ct)
- => Ok(await schedule.AnnouncementsAsync(ct));
-
- /// Prepares a private emission; a nonempty announcement publishes its presentation.
- [HttpPost("planned")]
- [ProducesResponseType(StatusCodes.Status201Created)]
- public async Task Prepare(PreparePodcastRequest request, CancellationToken ct)
- {
- var response = await schedule.PrepareAsync(User.GetUserId(), request, null, ct);
- return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
- }
-
- /// Edits an owned emission before its first start.
- [HttpPut("{id:guid}/planned")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task Update(Guid id, PreparePodcastRequest request, CancellationToken ct)
- => Ok(await schedule.PrepareAsync(User.GetUserId(), request, id, ct));
-
- /// Manually starts a prepared emission; retries retain the same broadcast identifier.
- [HttpPost("{id:guid}/start")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task Start(Guid id, CancellationToken ct)
- => Ok(await schedule.StartAsync(id, User.GetUserId(), ct));
+ => Ok(await processor.ListAsync(ct));
/// Starts a broadcast owned by the current user.
[HttpPost]
@@ -69,7 +41,7 @@ public sealed class PodcastController(ILiveProcessor processor, IPodcastSchedule
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> Get(Guid id, CancellationToken ct)
- => Ok(await schedule.GetAsync(id, User.GetUserId(), ct));
+ => Ok(await processor.GetAsync(id, ct));
/// Commits an MPEG-TS segment. Identical retries are idempotent.
[HttpPut("{id:guid}/segments/{sequence:int}")]
@@ -83,43 +55,17 @@ public sealed class PodcastController(ILiveProcessor processor, IPodcastSchedule
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
- await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct);
- return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
+ return Ok(await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct));
}
- /// Ends the broadcast and schedules preparation of its MP4 replay.
+ /// Ends the broadcast and publishes its retained HLS replay.
[HttpPost("{id:guid}/complete")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> Complete(Guid id, CancellationToken ct)
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
- await processor.CompleteAsync(id, ownerId, ct);
- return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
- }
-
- /// Serves a completed MP4 replay with authenticated byte-range support.
- [HttpGet("{id:guid}/replay.mp4")]
- [HttpHead("{id:guid}/replay.mp4")]
- [Produces("video/mp4")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status206PartialContent)]
- [ProducesResponseType(StatusCodes.Status409Conflict)]
- public async Task Replay(Guid id, CancellationToken ct)
- {
- Response.Headers.CacheControl = "private, no-store";
- return File(await processor.OpenReplayAsync(id, ct), "video/mp4", enableRangeProcessing: true);
- }
-
- /// Retries failed MP4 preparation. Only the broadcast owner can retry.
- [HttpPost("{id:guid}/replay/retry")]
- [ProducesResponseType(StatusCodes.Status202Accepted)]
- public async Task 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);
+ return Ok(await processor.CompleteAsync(id, ownerId, ct));
}
/// Serves the authenticated event playlist, terminated for completed or interrupted replays.
@@ -156,7 +102,7 @@ public sealed class PodcastController(ILiveProcessor processor, IPodcastSchedule
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
- await schedule.DeleteAsync(id, ownerId, ct);
+ await processor.DeleteAsync(id, ownerId, ct);
return NoContent();
}
}
diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
index bb41168b8..2ee2d1b3d 100644
--- a/src/Yavsc.Api/Controllers/accounting/AccountController.cs
+++ b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
@@ -39,12 +39,11 @@ namespace Yavsc.WebApi.Controllers
private readonly UserManager _userManager;
private readonly ITrueEmailSender _emailSender;
private readonly ICalendarManager _calendarManager;
- private readonly CallSignalingService _calls;
public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions siteSettings, UserManager userManager,
- ITrueEmailSender emailSender, ICalendarManager calendarManager, CallSignalingService calls)
+ ITrueEmailSender emailSender, ICalendarManager calendarManager)
{
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext;
@@ -52,7 +51,6 @@ namespace Yavsc.WebApi.Controllers
_userManager = userManager;
_emailSender = emailSender;
_calendarManager = calendarManager;
- _calls = calls;
}
[HttpGet("me")]
@@ -88,8 +86,7 @@ namespace Yavsc.WebApi.Controllers
Avatar = userData.Avatar,
FullName = userData.FullName,
Address = userData.PostalAddress?.Address ?? string.Empty,
- DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar,
- HideIpAddressInCalls = userData.HideIpAddressInCalls
+ DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar
};
user.BankInfoSummary = userData.BankInfo is { Count: > 0 }
? string.Join(" | ", userData.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
@@ -154,11 +151,6 @@ 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." });
@@ -180,8 +172,6 @@ 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();
@@ -222,16 +212,8 @@ namespace Yavsc.WebApi.Controllers
user.DedicatedGoogleCalendar = request.GoogleCalendarId.Trim();
}
- if (request.HideIpAddressInCalls is bool hideIpAddressInCalls)
- {
- user.HideIpAddressInCalls = hideIpAddressInCalls;
- }
-
await _dbContext.SaveChangesAsync(uid);
- 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 });
+ return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar });
}
[HttpPut("preferences/monthly-email")]
@@ -573,7 +555,6 @@ namespace Yavsc.WebApi.Controllers
}
[HttpGet("performer-profile")]
- [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task GetPerformerProfile()
{
var uid = User.GetUserId();
@@ -599,7 +580,6 @@ 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,
@@ -632,20 +612,6 @@ 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)
{
@@ -671,7 +637,7 @@ namespace Yavsc.WebApi.Controllers
AcceptNotifications = model.AcceptNotifications,
AcceptPublicContact = model.AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
- OrganizationAddress = new Yavsc.Models.Relationship.Location { Address = string.Empty }
+ OrganizationAddress = new Yavsc.Models.Relationship.Location()
};
_dbContext.Performers.Add(profile);
}
@@ -686,9 +652,6 @@ 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)
diff --git a/src/Yavsc.Api/Podcast.http b/src/Yavsc.Api/Podcast.http
index 670d9cd2e..d309fd234 100644
--- a/src/Yavsc.Api/Podcast.http
+++ b/src/Yavsc.Api/Podcast.http
@@ -2,27 +2,6 @@
@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}}
@@ -57,19 +36,6 @@ 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}}
diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs
index bb5bf98b1..132cb26c7 100644
--- a/src/Yavsc.Api/Program.cs
+++ b/src/Yavsc.Api/Program.cs
@@ -87,11 +87,6 @@ internal class Program
.AddJsonProtocol(options =>
options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton();
- services.Configure(
- builder.Configuration.GetSection(AudioVideoCallOptions.SectionName));
- services.AddSingleton(TimeProvider.System);
- services.AddSingleton();
- services.AddHostedService(provider => provider.GetRequiredService());
// DbContextBuilder
services.AddDbContext(options =>
diff --git a/src/Yavsc.Api/Services/PodcastReplayWorker.cs b/src/Yavsc.Api/Services/PodcastReplayWorker.cs
deleted file mode 100644
index 68020f69f..000000000
--- a/src/Yavsc.Api/Services/PodcastReplayWorker.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Yavsc.Services;
-
-namespace Yavsc.Api.Services;
-
-public sealed class PodcastReplayWorker(
- ILiveProcessor processor, IPodcastReplayRemuxer remuxer, ILogger 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; }
- }
- }
-}
diff --git a/src/Yavsc.Api/Services/PodcastScheduleService.cs b/src/Yavsc.Api/Services/PodcastScheduleService.cs
deleted file mode 100644
index 5f10a0ffd..000000000
--- a/src/Yavsc.Api/Services/PodcastScheduleService.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-#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 PrepareAsync(string ownerId, PreparePodcastRequest request, Guid? id, CancellationToken ct);
- Task StartAsync(Guid id, string ownerId, CancellationToken ct);
- Task> ListAsync(string ownerId, CancellationToken ct);
- Task GetAsync(Guid id, string ownerId, CancellationToken ct);
- Task> AnnouncementsAsync(CancellationToken ct);
- Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
-}
-
-public sealed class PodcastScheduleService(ApplicationDbContext db, ILiveProcessor processor,
- IOptions 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 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 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> 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 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> 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 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
- };
-}
diff --git a/src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs b/src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs
index 005fd448f..caca0b132 100644
--- a/src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs
+++ b/src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs
@@ -8,9 +8,6 @@ public static class PodcastServiceCollectionExtensions
{
services.Configure(configuration.GetSection("Podcast"));
services.AddSingleton();
- services.AddScoped();
- services.AddSingleton();
- services.AddHostedService();
services.AddOpenApi("podcasts", options => options.ShouldInclude = description => description.GroupName == "podcasts");
return services;
}
diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj
index 34a7e1bbb..d07177482 100644
--- a/src/Yavsc.Api/Yavsc.Api.csproj
+++ b/src/Yavsc.Api/Yavsc.Api.csproj
@@ -5,10 +5,10 @@
1c73094f-959f-4211-b1a1-6a69b236c283
Yavsc.Api
true
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj
index a4896ec80..eed0c89a7 100644
--- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj
+++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj
@@ -6,10 +6,10 @@
Yavsc.Blogs
https://forgejo.pschneider.fr/notazof/yavsc
true
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-calls.1+472.Branch.feat-calls.Sha.d8200cb98ddef152313ef1f102b866c50f107cbd
- 1.1.0-feat-calls.1
+ 1.0.8.0
+ 1.0.8.0
+ 1.0.8-458+Branch.main.Sha.ad92744a94c829947994ffef5e8a6598f62868f8
+ 1.0.8-458
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index e3d76169d..f6b211018 100644
--- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
@@ -664,13 +664,8 @@ 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"))
@@ -721,8 +716,6 @@ 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)
);
diff --git a/src/Yavsc.Org/Controllers/DonationController.cs b/src/Yavsc.Org/Controllers/DonationController.cs
deleted file mode 100644
index f3e7d6565..000000000
--- a/src/Yavsc.Org/Controllers/DonationController.cs
+++ /dev/null
@@ -1,232 +0,0 @@
-#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 siteOptions, ILogger logger) : Controller
-{
- [HttpGet]
- public async Task 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 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 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 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 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 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 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 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 Cancel(Guid id, CancellationToken ct)
- {
- var donation = await OwnedAsync(id, ct);
- if (donation == null) return NotFound();
- return View(donation);
- }
-
- [HttpPost, ValidateAntiForgeryToken]
- public async Task 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 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 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 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");
- }
-}
diff --git a/src/Yavsc.Org/Controllers/PodcastController.cs b/src/Yavsc.Org/Controllers/PodcastController.cs
deleted file mode 100644
index f99f9958a..000000000
--- a/src/Yavsc.Org/Controllers/PodcastController.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-#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 logger) : Controller
-{
- [AllowAnonymous, HttpGet]
- public async Task Announcements(CancellationToken ct)
- {
- using var response = await api.SendAsync(HttpMethod.Get, "announcements", null, ct, publicAnnouncement: true);
- var announcements = await response.Content.ReadFromJsonAsync>(ct)
- ?? throw new JsonException("Empty podcast announcements response.");
- return View(announcements);
- }
-
- [HttpGet]
- public async Task Index(CancellationToken ct)
- {
- var podcasts = await api.GetAsync>("", 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 Playlist(Guid id, CancellationToken ct)
- {
- var manifest = await api.GetAsync(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 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 Replay(Guid id, CancellationToken ct)
- => ProxyAsync($"{id}/replay.mp4", "video/mp4", ct);
-
- private async Task 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(), message))
- : StatusCode(status, new ProblemDetails { Status = status, Title = message });
- }
-}
diff --git a/src/Yavsc.Org/Controllers/RawPayPalNotificationAttribute.cs b/src/Yavsc.Org/Controllers/RawPayPalNotificationAttribute.cs
deleted file mode 100644
index f3277e137..000000000
--- a/src/Yavsc.Org/Controllers/RawPayPalNotificationAttribute.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-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();
- context.ValueProviderFactories.RemoveType();
- context.ValueProviderFactories.RemoveType();
- }
-
- public void OnResourceExecuted(ResourceExecutedContext context) { }
-}
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index b271d3730..38ce39fb1 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -92,10 +92,6 @@ public static class HostingExtensions
// Add the system clock service
_ = services.AddSingleton();
_ = services.AddSingleton();
- services.AddHttpClient()
- .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
- services.AddHttpClient()
- .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
_ = services.AddTransient();
AddIdentityDBAndStores(builder)
@@ -282,8 +278,6 @@ 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()));
});
diff --git a/src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs b/src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs
deleted file mode 100644
index cd37f9648..000000000
--- a/src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs
+++ /dev/null
@@ -1,4969 +0,0 @@
-//
-using System;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using Yavsc.Models;
-
-#nullable disable
-
-namespace Yavsc.Migrations
-{
- [DbContext(typeof(ApplicationDbContext))]
- [Migration("20261025155604_HideIpAddressInCalls")]
- partial class HideIpAddressInCalls
- {
- ///
- protected override void BuildTargetModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "10.0.13")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
-
- b.Property("AllowedAccessTokenSigningAlgorithms")
- .HasColumnType("text");
-
- b.Property("Created")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Description")
- .HasColumnType("text");
-
- b.Property("DisplayName")
- .HasColumnType("text");
-
- b.Property("Enabled")
- .HasColumnType("boolean");
-
- b.Property("LastAccessed")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Name")
- .HasColumnType("text");
-
- b.Property("NonEditable")
- .HasColumnType("boolean");
-
- b.Property("ShowInDiscoveryDocument")
- .HasColumnType("boolean");
-
- b.Property("Updated")
- .HasColumnType("timestamp with time zone");
-
- b.HasKey("Id");
-
- b.ToTable("ApiResources");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("ApiResourceId")
- .HasColumnType("integer");
-
- b.Property("ApiResourceId1")
- .HasColumnType("integer");
-
- b.Property("Type")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ApiResourceId");
-
- b.HasIndex("ApiResourceId1");
-
- b.ToTable("ApiResourceClaims");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceProperty", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("ApiResourceId")
- .HasColumnType("integer");
-
- b.Property("ApiResourceId1")
- .HasColumnType("integer");
-
- b.Property("Key")
- .HasColumnType("text");
-
- b.Property("Value")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ApiResourceId");
-
- b.HasIndex("ApiResourceId1");
-
- b.ToTable("ApiResourceProperties");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceScope", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("ApiResourceId")
- .HasColumnType("integer");
-
- b.Property("ApiResourceId1")
- .HasColumnType("integer");
-
- b.Property("Scope")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ApiResourceId");
-
- b.HasIndex("ApiResourceId1");
-
- b.ToTable("ApiResourceScopes");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceSecret", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("ApiResourceId")
- .HasColumnType("integer");
-
- b.Property("ApiResourceId1")
- .HasColumnType("integer");
-
- b.Property("Created")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Description")
- .HasColumnType("text");
-
- b.Property("Expiration")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Type")
- .HasColumnType("text");
-
- b.Property("Value")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ApiResourceId");
-
- b.HasIndex("ApiResourceId1");
-
- b.ToTable("ApiResourceSecrets");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScope", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
-
- b.Property("Description")
- .HasColumnType("text");
-
- b.Property("DisplayName")
- .HasColumnType("text");
-
- b.Property("Emphasize")
- .HasColumnType("boolean");
-
- b.Property("Enabled")
- .HasColumnType("boolean");
-
- b.Property("Name")
- .HasColumnType("text");
-
- b.Property("Required")
- .HasColumnType("boolean");
-
- b.Property("ShowInDiscoveryDocument")
- .HasColumnType("boolean");
-
- b.HasKey("Id");
-
- b.ToTable("ApiScopes");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeClaim", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("ScopeId")
- .HasColumnType("integer");
-
- b.Property("ScopeId1")
- .HasColumnType("integer");
-
- b.Property("Type")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ScopeId");
-
- b.HasIndex("ScopeId1");
-
- b.ToTable("ApiScopeClaims");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeProperty", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("Key")
- .HasColumnType("text");
-
- b.Property("ScopeId")
- .HasColumnType("integer");
-
- b.Property("ScopeId1")
- .HasColumnType("integer");
-
- b.Property("Value")
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("ScopeId");
-
- b.HasIndex("ScopeId1");
-
- b.ToTable("ApiScopeProperties");
- });
-
- modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.Client", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
-
- b.Property("AbsoluteRefreshTokenLifetime")
- .HasColumnType("integer");
-
- b.Property("AccessTokenLifetime")
- .HasColumnType("integer");
-
- b.Property("AccessTokenType")
- .HasColumnType("integer");
-
- b.Property("AllowAccessTokensViaBrowser")
- .HasColumnType("boolean");
-
- b.Property("AllowOfflineAccess")
- .HasColumnType("boolean");
-
- b.Property("AllowPlainTextPkce")
- .HasColumnType("boolean");
-
- b.Property("AllowRememberConsent")
- .HasColumnType("boolean");
-
- b.Property("AllowedIdentityTokenSigningAlgorithms")
- .HasColumnType("text");
-
- b.Property("AlwaysIncludeUserClaimsInIdToken")
- .HasColumnType("boolean");
-
- b.Property("AlwaysSendClientClaims")
- .HasColumnType("boolean");
-
- b.Property("AuthorizationCodeLifetime")
- .HasColumnType("integer");
-
- b.Property("BackChannelLogoutSessionRequired")
- .HasColumnType("boolean");
-
- b.Property("BackChannelLogoutUri")
- .HasColumnType("text");
-
- b.Property("ClientClaimsPrefix")
- .HasColumnType("text");
-
- b.Property("ClientId")
- .HasColumnType("text");
-
- b.Property("ClientName")
- .HasColumnType("text");
-
- b.Property("ClientUri")
- .HasColumnType("text");
-
- b.Property("ConsentLifetime")
- .HasColumnType("integer");
-
- b.Property("Created")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Description")
- .HasColumnType("text");
-
- b.Property("DeviceCodeLifetime")
- .HasColumnType("integer");
-
- b.Property("EnableLocalLogin")
- .HasColumnType("boolean");
-
- b.Property("Enabled")
- .HasColumnType("boolean");
-
- b.Property("FrontChannelLogoutSessionRequired")
- .HasColumnType("boolean");
-
- b.Property("FrontChannelLogoutUri")
- .HasColumnType("text");
-
- b.Property("IdentityTokenLifetime")
- .HasColumnType("integer");
-
- b.Property("IncludeJwtId")
- .HasColumnType("boolean");
-
- b.Property("LastAccessed")
- .HasColumnType("timestamp with time zone");
-
- b.Property("LogoUri")
- .HasColumnType("text");
-
- b.Property("NonEditable")
- .HasColumnType("boolean");
-
- b.Property("PairWiseSubjectSalt")
- .HasColumnType("text");
-
- b.Property("ProtocolType")
- .HasColumnType("text");
-
- b.Property("RefreshTokenExpiration")
- .HasColumnType("integer");
-
- b.Property("RefreshTokenUsage")
- .HasColumnType("integer");
-
- b.Property("RequireClientSecret")
- .HasColumnType("boolean");
-
- b.Property("RequireConsent")
- .HasColumnType("boolean");
-
- b.Property("RequirePkce")
- .HasColumnType("boolean");
-
- b.Property("RequireRequestObject")
- .HasColumnType("boolean");
-
- b.Property("SlidingRefreshTokenLifetime")
- .HasColumnType("integer");
-
- b.Property