Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Core/Api/MosaicEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using OpenStreamMS.Services.Mosaic;

namespace OpenStreamMS.Core.Api;

/// <param name="Id">Sesión de OpenStreamMS.</param>
/// <param name="Name">Nombre de la sesión.</param>
/// <param name="Area">Parte de la cuadrícula que ocupa (Full, Left, Right, TopLeft…).</param>
public record MosaicMemberDto(Guid Id, string Name, string Area);

/// <param name="Host">Sesión anfitriona (la que se retransmite).</param>
/// <param name="Members">Por orden de llegada; la primera es la anfitriona. El mando N va a la sesión N.</param>
/// <param name="Stacked">Con dos sesiones, una encima de otra en vez de una al lado de otra.</param>
public record MosaicDto(Guid Host, List<MosaicMemberDto> Members, bool Stacked);

/// <param name="Stacked">true: una encima de otra (arriba/abajo); false: una al lado de otra (izquierda/derecha).</param>
public record MosaicLayoutRequest(bool Stacked);

/// <summary>
/// Endpoints REST del mosaico (ver <see cref="MosaicService"/>). Mismo estilo que <see cref="SeatEndpoints"/>.
/// </summary>
public static class MosaicEndpoints
{
public static IEndpointRouteBuilder MapMosaicApi(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/sessions/{id:guid}/mosaic").WithTags("Mosaic");

group.MapGet("/", (Guid id, MosaicService mosaic, StreamSessionService sessions) =>
sessions.Get(id) is null ? Results.NotFound() : Results.Ok(ToDto(id, mosaic, sessions)))
.WithName("GetMosaic")
.WithSummary("Mosaico de una sesión anfitriona")
.WithDescription("Sesiones que comparten el stream de la anfitriona, por orden de llegada y con su parte de la pantalla. " +
"Sin mosaico, la lista solo tiene la anfitriona a pantalla entera.");

group.MapPost("/{guestId:guid}", async (Guid id, Guid guestId, MosaicService mosaic, StreamSessionService sessions, CancellationToken ct) =>
await Action(() => mosaic.JoinAsync(id, guestId, ct), id, mosaic, sessions))
.WithName("JoinMosaic")
.WithSummary("Unir una sesión al mosaico")
.WithDescription("""
Une la sesión guestId al stream de la sesión anfitriona {id} (hasta cuatro en total), en la
siguiente parte libre: 2 sesiones en mitades, 3 o 4 en cuartos. La invitada se arranca si
hace falta y su Sunshine queda en pausa. Las sesiones se reconectan al tamaño de su parte
(~1-2 s cada una). Requiere captura RemoteIdd y un cliente conectado a la anfitriona.
""");

group.MapDelete("/{memberId:guid}", async (Guid id, Guid memberId, MosaicService mosaic, StreamSessionService sessions, CancellationToken ct) =>
await Action(() => mosaic.LeaveAsync(id, memberId, ct), id, mosaic, sessions))
.WithName("LeaveMosaic")
.WithSummary("Sacar una sesión del mosaico")
.WithDescription("Saca la sesión del mosaico: vuelve a su resolución y a servirse por su Sunshine. " +
"Sacar a la anfitriona deshace el mosaico.");

group.MapPut("/layout", async (Guid id, MosaicLayoutRequest req, MosaicService mosaic, StreamSessionService sessions, CancellationToken ct) =>
await Action(() => mosaic.SetStackedAsync(id, req.Stacked, ct), id, mosaic, sessions))
.WithName("SetMosaicLayout")
.WithSummary("Disposición con dos sesiones")
.WithDescription("Con dos sesiones, una al lado de otra (stacked: false) o una encima de otra (stacked: true). " +
"Las dos se reconectan al tamaño de su nueva mitad. Con tres o cuatro se recuerda para cuando vuelvan a ser dos.");

return app;
}

private static async Task<IResult> Action(Func<Task> op, Guid host, MosaicService mosaic, StreamSessionService sessions)
{
try
{
await op();
return Results.Ok(ToDto(host, mosaic, sessions));
}
catch (KeyNotFoundException) { return Results.NotFound(); }
catch (InvalidOperationException ex) { return Results.BadRequest(ex.Message); }
}

private static MosaicDto ToDto(Guid host, MosaicService mosaic, StreamSessionService sessions)
{
var ids = mosaic.Members(host);
if (ids.Count == 0) ids = [host];
var areas = MosaicLayout.Areas(ids.Count, mosaic.IsStacked(host));
return new MosaicDto(host, ids.Select((id, i) =>
new MosaicMemberDto(id, sessions.Get(id)?.Name ?? "", areas[i].ToString())).ToList(), mosaic.IsStacked(host));
}
}
13 changes: 11 additions & 2 deletions Core/Api/SessionEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,15 @@ public record SessionResponse(
/// <summary>Ritmo de composición de la sesión RDP.</summary>
RdpCompositionMode RdpComposition = RdpCompositionMode.Smooth,
/// <summary>Paso del arranque en el que falló (user, rdp, profile, unlock, sunshine); null si no hay error.</summary>
string? FailedPhase = null
string? FailedPhase = null,
/// <summary>Anfitriona del mosaico en el que está la sesión (la propia si es la anfitriona); null fuera de un mosaico.</summary>
Guid? MosaicHost = null,
/// <summary>Posición en el mosaico por orden de llegada (0 = anfitriona; el mando N va a la N).</summary>
int? MosaicIndex = null,
/// <summary>Sesiones en el mosaico de la anfitriona (solo en la anfitriona).</summary>
int? MosaicCount = null,
/// <summary>Solo en la anfitriona: con dos sesiones, una encima de otra en vez de una al lado de otra.</summary>
bool MosaicStacked = false
);

/// <summary>Usuario dedicado o perfil osms-* que ya no usa ninguna sesión.</summary>
Expand Down Expand Up @@ -963,5 +971,6 @@ static async Task<IResult> UnpairAllClients(
s.Isolated ? s.GetIsolatedUser() : null,
StreamSessionService.SteamLoginState(s),
s.RdpDisplayScale, s.RdpComposition,
s.ErrorMessage is null ? null : s.FailedPhase);
s.ErrorMessage is null ? null : s.FailedPhase,
s.MosaicHost, s.MosaicIndex, s.MosaicCount, s.MosaicStacked);
}
11 changes: 11 additions & 0 deletions Core/Api/StreamSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,15 @@ public string GetIsolatedUser() =>
/// </summary>
public int? ClientsMoonlight { get; set; }
public int? ClientsWebRtc { get; set; }
/// <summary>
/// Sesión anfitriona del mosaico en el que está esta sesión (la propia si es la anfitriona);
/// null fuera de un mosaico. Lo mantiene <see cref="OpenStreamMS.Services.Mosaic.MosaicService"/>. No se persiste.
/// </summary>
public Guid? MosaicHost { get; set; }
/// <summary>Posición en el mosaico por orden de llegada (0 = anfitriona; el mando N va a la N). No se persiste.</summary>
public int? MosaicIndex { get; set; }
/// <summary>Sesiones en el mosaico, solo en la anfitriona. No se persiste.</summary>
public int? MosaicCount { get; set; }
/// <summary>Solo en la anfitriona: con dos sesiones, una encima de otra. No se persiste.</summary>
public bool MosaicStacked { get; set; }
}
7 changes: 7 additions & 0 deletions Core/Api/StreamSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,13 @@ private void AdoptStreamProtocolFromSunshine(StreamSession session)
private static bool UsesRemoteIdd(StreamSession session) =>
WantsRemoteIdd(session) && RdpWrapperManager.IsRemoteIddUsable();

/// <summary>La sesión se captura con RemoteIdd (requisito del mosaico).</summary>
public static bool CapturesWithRemoteIdd(StreamSession session) => UsesRemoteIdd(session);

/// <summary>Carpeta <c>config</c> del Sunshine de la sesión (la de <c>sunshine.conf</c>).</summary>
public static string SunshineConfigDir(Guid sessionId) =>
Path.Combine(GetSunshineInstanceDir(sessionId), "config");

private static bool WantsRemoteIdd(StreamSession session) =>
!session.Local &&
(string.IsNullOrWhiteSpace(session.Capture) || IsRemoteIddCapture(session));
Expand Down
81 changes: 80 additions & 1 deletion Core/wwwroot/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function renderCard(s) {
</div>
${power}
</div>
<div class="card-chips">${protos.join('')}${sessionSeatLine(s)}${clientsChip(s)}</div>
<div class="card-chips">${protos.join('')}${sessionSeatLine(s)}${clientsChip(s)}${mosaicChip(s)}</div>
${metricsSlot(s)}
${startProgress(s)}
${rdpWarning(s)}
Expand All @@ -197,8 +197,18 @@ function sessionMenu(e, id) {
const s = sessions.find(x => x.id === id);
if (!s) return;
const running = s.state === 'Running', busy = s.state === 'Starting' || s.state === 'Stopping';
const btn = e.currentTarget;
const mosaic = s.mosaicHost
? { icon: 'grid', label: t(s.mosaicHost === s.id ? 'mosaic.dissolve' : 'mosaic.leave'), run: () => leaveMosaic(s) }
: { icon: 'grid', label: t('mosaic.join'), run: () => mosaicHostMenu(btn, s), disabled: s.local || busy, hint: t('mosaic.join_disabled') };
// Con dos sesiones, la anfitriona elige una al lado de otra o una encima de otra.
const layout = s.mosaicHost === s.id && s.mosaicCount === 2
? [{ icon: 'grid', label: t(s.mosaicStacked ? 'mosaic.sideBySide' : 'mosaic.stack'), run: () => setMosaicStacked(s, !s.mosaicStacked) }]
: [];
openMenu(e, [
{ icon: 'restart', label: t('btn.restart'), run: () => restartSession(s.id, s.name) },
mosaic,
...layout,
{ icon: 'link', label: t('ux.connect.btn'), run: () => openSunMgrModal(s.id), disabled: !running, hint: t('btn.sunshine_disabled_title') },
{ icon: 'external', label: t('btn.panel'), run: () => openSunshinePanel(s.id), disabled: !running, hint: t('btn.panel_disabled_title') },
{ icon: 'log', label: t('btn.log'), run: () => openLogModal(s.id, s.name) },
Expand All @@ -217,6 +227,74 @@ function clientsChip(s) {
n === 0 ? t('ux.clients.none') : n === 1 ? t('ux.clients.one') : t('ux.clients.many', { n }))}</span>`;
}

// ── Mosaico: hasta cuatro sesiones en el stream de una (ver MosaicService) ─────
// Partes por número de sesiones, como MosaicLayout.Areas: celdas 0 arriba-izq., 1 arriba-der.,
// 2 abajo-izq., 3 abajo-der.
const MOSAIC_CELLS = { 1: [[0, 1, 2, 3]], 2: [[0, 2], [1, 3]], 3: [[0], [1], [2]], 4: [[0], [1], [2], [3]] };
const MOSAIC_CELLS_STACKED = [[0, 1], [2, 3]];

function mosaicMembers(hostId) {
return sessions.filter(x => x.mosaicHost === hostId).sort((a, b) => (a.mosaicIndex ?? 0) - (b.mosaicIndex ?? 0));
}

// Etiqueta con un mapa 2×2: la parte de esta sesión resaltada; el título dice qué mando va a cada una.
function mosaicChip(s) {
if (!s.mosaicHost) return '';
const members = mosaicMembers(s.mosaicHost);
const host = sessions.find(x => x.id === s.mosaicHost);
if (!host || members.length < 2) return '';
const parts = members.length === 2 && host.mosaicStacked ? MOSAIC_CELLS_STACKED : MOSAIC_CELLS[Math.min(members.length, 4)];
const own = parts[s.mosaicIndex ?? 0] || [];
const used = new Set(parts.flat());
const cells = [0, 1, 2, 3].map(c =>
`<i class="${own.includes(c) ? 'own' : used.has(c) ? 'used' : ''}"></i>`).join('');
const list = members.map((m, i) => t('mosaic.member', { n: i + 1, name: m.name })).join(' · ');
const label = s.id === host.id
? t('mosaic.hostChip', { n: members.length })
: t('mosaic.guestChip', { name: host.name });
return `<span class="chip chip-mosaic active" title="${esc(t('mosaic.chip_title', { list }))}"><span class="mosaic-map" aria-hidden="true">${cells}</span>${esc(label)}</span>`;
}

// Sesiones a cuyo stream se puede unir s: en marcha, retransmitiendo (o sin dato), sin mosaico
// o anfitrionas con hueco. s no puede ser ya anfitriona de otras.
function mosaicHostMenu(btn, s) {
const hosts = sessions.filter(h => h.id !== s.id && h.state === 'Running' && !h.local &&
(!h.mosaicHost || h.mosaicHost === h.id) && (h.mosaicCount || 1) < 4 &&
(h.clientsMoonlight == null || h.clientsWebRtc == null || h.clientsMoonlight + h.clientsWebRtc > 0));
if (!hosts.length) { toast(t('mosaic.noHosts'), true); return; }
// Después del clic actual: si se abre dentro de él, el clic llega al document, que cierra
// cualquier menú cuando el clic no fue dentro (el botón pulsado ya no está en la página).
// Si la tarjeta se repintó entretanto, el botón "⋯" es otro.
const anchor = () => btn.isConnected ? btn : document.querySelector(`#card-${s.id} .more-btn`) || btn;
setTimeout(() => openMenu({ stopPropagation() {}, currentTarget: anchor() },
hosts.map(h => ({ icon: 'grid', label: t('mosaic.joinTo', { name: h.name, n: (h.mosaicCount || 1) + 1 }), run: () => joinMosaic(h, s) }))));
}

async function joinMosaic(host, guest) {
toast(t('mosaic.joining', { name: guest.name, host: host.name }));
const res = await api('POST', `/api/sessions/${host.id}/mosaic/${guest.id}`);
if (!res) return;
if (!res.ok) { toast(await I18n.responseError(res), true); loadSessions(); return; }
toast(t('mosaic.joined', { name: guest.name, host: host.name }));
loadSessions();
}

async function setMosaicStacked(host, stacked) {
toast(t('mosaic.relayout'));
const res = await api('PUT', `/api/sessions/${host.id}/mosaic/layout`, { stacked });
if (!res) return;
if (!res.ok) { toast(await I18n.responseError(res), true); return; }
loadSessions();
}

async function leaveMosaic(s) {
const res = await api('DELETE', `/api/sessions/${s.mosaicHost}/mosaic/${s.id}`);
if (!res) return;
if (!res.ok) { toast(await I18n.responseError(res), true); return; }
toast(t(s.mosaicHost === s.id ? 'mosaic.dissolved' : 'mosaic.left', { name: s.name }));
loadSessions();
}

// Puesto local asociado a la sesión: su nombre y si ahora mismo la está usando.
function sessionSeatLine(s) {
const seat = seats.find(x => x.sessionId === s.id);
Expand Down Expand Up @@ -1308,6 +1386,7 @@ async function powerAction(kind) {
// Iconos de trazo (heredan el color del texto).
const UI_ICONS = {
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
grid: '<rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/>',
copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6"/><circle cx="12" cy="17.2" r=".6" fill="currentColor"/>',
Expand Down
19 changes: 18 additions & 1 deletion Core/wwwroot/locales/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,5 +710,22 @@
"log.noMatches": "Няма редове, отговарящи на филтъра.",
"view.toggle": "Изглед на сесиите",
"view.cards": "Карти",
"view.list": "Списък"
"view.list": "Списък",
"mosaic.join": "Присъединяване към стрийма на друга сесия…",
"mosaic.join_disabled": "Не е налично за локална сесия или докато се стартира или спира.",
"mosaic.joinTo": "Присъединяване към {name} (част {n} от 4)",
"mosaic.leave": "Напускане на мозайката",
"mosaic.dissolve": "Прекратяване на мозайката",
"mosaic.noHosts": "В момента никоя друга сесия не стриймва към клиент.",
"mosaic.joining": "{name} се присъединява към стрийма на {host}… Сесиите се свързват отново с размера на своята част.",
"mosaic.joined": "{name} вече е в стрийма на {host}.",
"mosaic.left": "{name} напусна мозайката.",
"mosaic.dissolved": "Мозайката е прекратена: {name} отново е на цял екран.",
"mosaic.hostChip": "Мозайка {n}/4",
"mosaic.guestChip": "В мозайката на {name}",
"mosaic.member": "Контролер {n} → {name}",
"mosaic.chip_title": "До 4 сесии споделят този стрийм. Всеки контролер отива към една сесия по реда на свързване: {list}",
"mosaic.stack": "Едната над другата",
"mosaic.sideBySide": "Една до друга",
"mosaic.relayout": "Пренареждане… двете сесии се свързват отново с новата си половина."
}
19 changes: 18 additions & 1 deletion Core/wwwroot/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,5 +710,22 @@
"log.noMatches": "Filtru neodpovídá žádný řádek.",
"view.toggle": "Zobrazení relací",
"view.cards": "Karty",
"view.list": "Seznam"
"view.list": "Seznam",
"mosaic.join": "Připojit ke streamu jiné relace…",
"mosaic.join_disabled": "Není k dispozici pro místní relaci ani během spouštění či zastavování.",
"mosaic.joinTo": "Připojit k {name} (část {n} ze 4)",
"mosaic.leave": "Opustit mozaiku",
"mosaic.dissolve": "Ukončit mozaiku",
"mosaic.noHosts": "Žádná jiná relace teď nestreamuje do klienta.",
"mosaic.joining": "Připojování {name} ke streamu {host}… Relace se znovu připojí ve velikosti své části.",
"mosaic.joined": "{name} je nyní ve streamu {host}.",
"mosaic.left": "{name} opustila mozaiku.",
"mosaic.dissolved": "Mozaika ukončena: {name} je opět na celé obrazovce.",
"mosaic.hostChip": "Mozaika {n}/4",
"mosaic.guestChip": "V mozaice {name}",
"mosaic.member": "Ovladač {n} → {name}",
"mosaic.chip_title": "Tento stream sdílí až 4 relace. Každý ovladač patří jedné relaci podle pořadí připojení: {list}",
"mosaic.stack": "Dát nad sebe",
"mosaic.sideBySide": "Dát vedle sebe",
"mosaic.relayout": "Přeskládání… obě relace se znovu připojí ve své nové polovině."
}
19 changes: 18 additions & 1 deletion Core/wwwroot/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,5 +710,22 @@
"log.noMatches": "Keine Zeile entspricht dem Filter.",
"view.toggle": "Sitzungsansicht",
"view.cards": "Karten",
"view.list": "Liste"
"view.list": "Liste",
"mosaic.join": "Dem Stream einer anderen Sitzung beitreten…",
"mosaic.join_disabled": "Nicht verfügbar für eine lokale Sitzung oder während sie startet oder stoppt.",
"mosaic.joinTo": "{name} beitreten (Teil {n} von 4)",
"mosaic.leave": "Mosaik verlassen",
"mosaic.dissolve": "Mosaik beenden",
"mosaic.noHosts": "Gerade streamt keine andere Sitzung an einen Client.",
"mosaic.joining": "{name} tritt dem Stream von {host} bei… Die Sitzungen verbinden sich in der Größe ihres Teils neu.",
"mosaic.joined": "{name} ist jetzt im Stream von {host}.",
"mosaic.left": "{name} hat das Mosaik verlassen.",
"mosaic.dissolved": "Mosaik beendet: {name} ist wieder im Vollbild.",
"mosaic.hostChip": "Mosaik {n}/4",
"mosaic.guestChip": "Im Mosaik von {name}",
"mosaic.member": "Controller {n} → {name}",
"mosaic.chip_title": "Bis zu 4 Sitzungen teilen sich diesen Stream. Jeder Controller gehört in der Reihenfolge seiner Ankunft zu einer Sitzung: {list}",
"mosaic.stack": "Übereinander anordnen",
"mosaic.sideBySide": "Nebeneinander anordnen",
"mosaic.relayout": "Wird neu angeordnet… beide Sitzungen verbinden sich mit ihrer neuen Hälfte neu."
}
Loading
Loading