diff --git a/Core/Api/MosaicEndpoints.cs b/Core/Api/MosaicEndpoints.cs new file mode 100644 index 0000000..da6e281 --- /dev/null +++ b/Core/Api/MosaicEndpoints.cs @@ -0,0 +1,81 @@ +using OpenStreamMS.Services.Mosaic; + +namespace OpenStreamMS.Core.Api; + +/// Sesión de OpenStreamMS. +/// Nombre de la sesión. +/// Parte de la cuadrícula que ocupa (Full, Left, Right, TopLeft…). +public record MosaicMemberDto(Guid Id, string Name, string Area); + +/// Sesión anfitriona (la que se retransmite). +/// Por orden de llegada; la primera es la anfitriona. El mando N va a la sesión N. +/// Con dos sesiones, una encima de otra en vez de una al lado de otra. +public record MosaicDto(Guid Host, List Members, bool Stacked); + +/// true: una encima de otra (arriba/abajo); false: una al lado de otra (izquierda/derecha). +public record MosaicLayoutRequest(bool Stacked); + +/// +/// Endpoints REST del mosaico (ver ). Mismo estilo que . +/// +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 Action(Func 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)); + } +} diff --git a/Core/Api/SessionEndpoints.cs b/Core/Api/SessionEndpoints.cs index f95e64f..a46933c 100644 --- a/Core/Api/SessionEndpoints.cs +++ b/Core/Api/SessionEndpoints.cs @@ -256,7 +256,15 @@ public record SessionResponse( /// Ritmo de composición de la sesión RDP. RdpCompositionMode RdpComposition = RdpCompositionMode.Smooth, /// Paso del arranque en el que falló (user, rdp, profile, unlock, sunshine); null si no hay error. - string? FailedPhase = null + string? FailedPhase = null, + /// Anfitriona del mosaico en el que está la sesión (la propia si es la anfitriona); null fuera de un mosaico. + Guid? MosaicHost = null, + /// Posición en el mosaico por orden de llegada (0 = anfitriona; el mando N va a la N). + int? MosaicIndex = null, + /// Sesiones en el mosaico de la anfitriona (solo en la anfitriona). + int? MosaicCount = null, + /// Solo en la anfitriona: con dos sesiones, una encima de otra en vez de una al lado de otra. + bool MosaicStacked = false ); /// Usuario dedicado o perfil osms-* que ya no usa ninguna sesión. @@ -963,5 +971,6 @@ static async Task 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); } diff --git a/Core/Api/StreamSession.cs b/Core/Api/StreamSession.cs index 852f17f..1ca5ca4 100644 --- a/Core/Api/StreamSession.cs +++ b/Core/Api/StreamSession.cs @@ -226,4 +226,15 @@ public string GetIsolatedUser() => /// public int? ClientsMoonlight { get; set; } public int? ClientsWebRtc { get; set; } + /// + /// 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 . No se persiste. + /// + public Guid? MosaicHost { get; set; } + /// Posición en el mosaico por orden de llegada (0 = anfitriona; el mando N va a la N). No se persiste. + public int? MosaicIndex { get; set; } + /// Sesiones en el mosaico, solo en la anfitriona. No se persiste. + public int? MosaicCount { get; set; } + /// Solo en la anfitriona: con dos sesiones, una encima de otra. No se persiste. + public bool MosaicStacked { get; set; } } diff --git a/Core/Api/StreamSessionService.cs b/Core/Api/StreamSessionService.cs index 4bc0155..3de5a93 100644 --- a/Core/Api/StreamSessionService.cs +++ b/Core/Api/StreamSessionService.cs @@ -1307,6 +1307,13 @@ private void AdoptStreamProtocolFromSunshine(StreamSession session) private static bool UsesRemoteIdd(StreamSession session) => WantsRemoteIdd(session) && RdpWrapperManager.IsRemoteIddUsable(); + /// La sesión se captura con RemoteIdd (requisito del mosaico). + public static bool CapturesWithRemoteIdd(StreamSession session) => UsesRemoteIdd(session); + + /// Carpeta config del Sunshine de la sesión (la de sunshine.conf). + 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)); diff --git a/Core/wwwroot/app.js b/Core/wwwroot/app.js index d7ef27a..1557e49 100644 --- a/Core/wwwroot/app.js +++ b/Core/wwwroot/app.js @@ -170,7 +170,7 @@ function renderCard(s) { ${power} -
${protos.join('')}${sessionSeatLine(s)}${clientsChip(s)}
+
${protos.join('')}${sessionSeatLine(s)}${clientsChip(s)}${mosaicChip(s)}
${metricsSlot(s)} ${startProgress(s)} ${rdpWarning(s)} @@ -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) }, @@ -217,6 +227,74 @@ function clientsChip(s) { n === 0 ? t('ux.clients.none') : n === 1 ? t('ux.clients.one') : t('ux.clients.many', { n }))}`; } +// ── 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 => + ``).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 `${esc(label)}`; +} + +// 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); @@ -1308,6 +1386,7 @@ async function powerAction(kind) { // Iconos de trazo (heredan el color del texto). const UI_ICONS = { power: '', + grid: '', copy: '', download: '', help: '', diff --git a/Core/wwwroot/locales/bg.json b/Core/wwwroot/locales/bg.json index cb7e5cb..80d94fc 100644 --- a/Core/wwwroot/locales/bg.json +++ b/Core/wwwroot/locales/bg.json @@ -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": "Пренареждане… двете сесии се свързват отново с новата си половина." } diff --git a/Core/wwwroot/locales/cs.json b/Core/wwwroot/locales/cs.json index 554bc6c..cbe55cb 100644 --- a/Core/wwwroot/locales/cs.json +++ b/Core/wwwroot/locales/cs.json @@ -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ě." } diff --git a/Core/wwwroot/locales/de.json b/Core/wwwroot/locales/de.json index 6bab525..793a96b 100644 --- a/Core/wwwroot/locales/de.json +++ b/Core/wwwroot/locales/de.json @@ -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." } diff --git a/Core/wwwroot/locales/en.json b/Core/wwwroot/locales/en.json index c858ab0..7587511 100644 --- a/Core/wwwroot/locales/en.json +++ b/Core/wwwroot/locales/en.json @@ -710,5 +710,22 @@ "log.noMatches": "No lines match the filter.", "view.toggle": "Sessions view", "view.cards": "Cards", - "view.list": "List" + "view.list": "List", + "mosaic.join": "Join another session's stream…", + "mosaic.join_disabled": "Not available for a local session or while it is starting or stopping.", + "mosaic.joinTo": "Join {name} (part {n} of 4)", + "mosaic.leave": "Leave the mosaic", + "mosaic.dissolve": "End the mosaic", + "mosaic.noHosts": "No other session is streaming to a client right now.", + "mosaic.joining": "Joining {name} to {host}'s stream… The sessions reconnect to the size of their part.", + "mosaic.joined": "{name} is now in {host}'s stream.", + "mosaic.left": "{name} left the mosaic.", + "mosaic.dissolved": "Mosaic ended: {name} is back to full screen.", + "mosaic.hostChip": "Mosaic {n}/4", + "mosaic.guestChip": "In {name}'s mosaic", + "mosaic.member": "Controller {n} → {name}", + "mosaic.chip_title": "Up to 4 sessions share this stream. Each controller goes to one session in order of arrival: {list}", + "mosaic.stack": "Put one above the other", + "mosaic.sideBySide": "Put them side by side", + "mosaic.relayout": "Rearranging… both sessions reconnect to their new half." } diff --git a/Core/wwwroot/locales/en_GB.json b/Core/wwwroot/locales/en_GB.json index b8e9aef..576a847 100644 --- a/Core/wwwroot/locales/en_GB.json +++ b/Core/wwwroot/locales/en_GB.json @@ -710,5 +710,22 @@ "log.noMatches": "No lines match the filter.", "view.toggle": "Sessions view", "view.cards": "Cards", - "view.list": "List" + "view.list": "List", + "mosaic.join": "Join another session's stream…", + "mosaic.join_disabled": "Not available for a local session or while it is starting or stopping.", + "mosaic.joinTo": "Join {name} (part {n} of 4)", + "mosaic.leave": "Leave the mosaic", + "mosaic.dissolve": "End the mosaic", + "mosaic.noHosts": "No other session is streaming to a client right now.", + "mosaic.joining": "Joining {name} to {host}'s stream… The sessions reconnect to the size of their part.", + "mosaic.joined": "{name} is now in {host}'s stream.", + "mosaic.left": "{name} left the mosaic.", + "mosaic.dissolved": "Mosaic ended: {name} is back to full screen.", + "mosaic.hostChip": "Mosaic {n}/4", + "mosaic.guestChip": "In {name}'s mosaic", + "mosaic.member": "Controller {n} → {name}", + "mosaic.chip_title": "Up to 4 sessions share this stream. Each controller goes to one session in order of arrival: {list}", + "mosaic.stack": "Put one above the other", + "mosaic.sideBySide": "Put them side by side", + "mosaic.relayout": "Rearranging… both sessions reconnect to their new half." } diff --git a/Core/wwwroot/locales/en_US.json b/Core/wwwroot/locales/en_US.json index 0bbe5d6..afc0de2 100644 --- a/Core/wwwroot/locales/en_US.json +++ b/Core/wwwroot/locales/en_US.json @@ -710,5 +710,22 @@ "log.noMatches": "No lines match the filter.", "view.toggle": "Sessions view", "view.cards": "Cards", - "view.list": "List" + "view.list": "List", + "mosaic.join": "Join another session's stream…", + "mosaic.join_disabled": "Not available for a local session or while it is starting or stopping.", + "mosaic.joinTo": "Join {name} (part {n} of 4)", + "mosaic.leave": "Leave the mosaic", + "mosaic.dissolve": "End the mosaic", + "mosaic.noHosts": "No other session is streaming to a client right now.", + "mosaic.joining": "Joining {name} to {host}'s stream… The sessions reconnect to the size of their part.", + "mosaic.joined": "{name} is now in {host}'s stream.", + "mosaic.left": "{name} left the mosaic.", + "mosaic.dissolved": "Mosaic ended: {name} is back to full screen.", + "mosaic.hostChip": "Mosaic {n}/4", + "mosaic.guestChip": "In {name}'s mosaic", + "mosaic.member": "Controller {n} → {name}", + "mosaic.chip_title": "Up to 4 sessions share this stream. Each controller goes to one session in order of arrival: {list}", + "mosaic.stack": "Put one above the other", + "mosaic.sideBySide": "Put them side by side", + "mosaic.relayout": "Rearranging… both sessions reconnect to their new half." } diff --git a/Core/wwwroot/locales/es.json b/Core/wwwroot/locales/es.json index e4ec689..de77240 100644 --- a/Core/wwwroot/locales/es.json +++ b/Core/wwwroot/locales/es.json @@ -710,5 +710,22 @@ "log.noMatches": "Ninguna línea coincide con el filtro.", "view.toggle": "Vista de sesiones", "view.cards": "Tarjetas", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Unir al stream de otra sesión…", + "mosaic.join_disabled": "No disponible en una sesión local ni mientras arranca o se detiene.", + "mosaic.joinTo": "Unir a {name} (parte {n} de 4)", + "mosaic.leave": "Salir del mosaico", + "mosaic.dissolve": "Deshacer el mosaico", + "mosaic.noHosts": "Ahora mismo ninguna otra sesión está retransmitiendo a un cliente.", + "mosaic.joining": "Uniendo {name} al stream de {host}… Las sesiones se reconectan al tamaño de su parte.", + "mosaic.joined": "{name} ya está en el stream de {host}.", + "mosaic.left": "{name} ha salido del mosaico.", + "mosaic.dissolved": "Mosaico deshecho: {name} vuelve a pantalla completa.", + "mosaic.hostChip": "Mosaico {n}/4", + "mosaic.guestChip": "En el mosaico de {name}", + "mosaic.member": "Mando {n} → {name}", + "mosaic.chip_title": "Hasta 4 sesiones comparten este stream. Cada mando va a una sesión por orden de llegada: {list}", + "mosaic.stack": "Poner una encima de otra", + "mosaic.sideBySide": "Poner una al lado de otra", + "mosaic.relayout": "Recolocando… las dos sesiones se reconectan a su nueva mitad." } diff --git a/Core/wwwroot/locales/fr.json b/Core/wwwroot/locales/fr.json index a0d5ac8..3d34480 100644 --- a/Core/wwwroot/locales/fr.json +++ b/Core/wwwroot/locales/fr.json @@ -710,5 +710,22 @@ "log.noMatches": "Aucune ligne ne correspond au filtre.", "view.toggle": "Vue des sessions", "view.cards": "Cartes", - "view.list": "Liste" + "view.list": "Liste", + "mosaic.join": "Rejoindre le stream d'une autre session…", + "mosaic.join_disabled": "Indisponible pour une session locale ou pendant son démarrage ou son arrêt.", + "mosaic.joinTo": "Rejoindre {name} (partie {n} sur 4)", + "mosaic.leave": "Quitter la mosaïque", + "mosaic.dissolve": "Terminer la mosaïque", + "mosaic.noHosts": "Aucune autre session ne diffuse vers un client pour le moment.", + "mosaic.joining": "{name} rejoint le stream de {host}… Les sessions se reconnectent à la taille de leur partie.", + "mosaic.joined": "{name} est maintenant dans le stream de {host}.", + "mosaic.left": "{name} a quitté la mosaïque.", + "mosaic.dissolved": "Mosaïque terminée : {name} repasse en plein écran.", + "mosaic.hostChip": "Mosaïque {n}/4", + "mosaic.guestChip": "Dans la mosaïque de {name}", + "mosaic.member": "Manette {n} → {name}", + "mosaic.chip_title": "Jusqu'à 4 sessions partagent ce stream. Chaque manette va à une session par ordre d'arrivée : {list}", + "mosaic.stack": "Mettre l'une au-dessus de l'autre", + "mosaic.sideBySide": "Mettre côte à côte", + "mosaic.relayout": "Réorganisation… les deux sessions se reconnectent à leur nouvelle moitié." } diff --git a/Core/wwwroot/locales/hu.json b/Core/wwwroot/locales/hu.json index ba99a28..293006b 100644 --- a/Core/wwwroot/locales/hu.json +++ b/Core/wwwroot/locales/hu.json @@ -710,5 +710,22 @@ "log.noMatches": "Egy sor sem felel meg a szűrőnek.", "view.toggle": "Munkamenetek nézete", "view.cards": "Kártyák", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Csatlakozás egy másik munkamenet streamjéhez…", + "mosaic.join_disabled": "Helyi munkamenetnél, illetve indítás vagy leállítás közben nem érhető el.", + "mosaic.joinTo": "Csatlakozás ehhez: {name} ({n}. rész a 4-ből)", + "mosaic.leave": "Kilépés a mozaikból", + "mosaic.dissolve": "Mozaik megszüntetése", + "mosaic.noHosts": "Jelenleg egyetlen másik munkamenet sem streamel klienshez.", + "mosaic.joining": "{name} csatlakozik {host} streamjéhez… A munkamenetek a saját részük méretével csatlakoznak újra.", + "mosaic.joined": "{name} most már {host} streamjében van.", + "mosaic.left": "{name} kilépett a mozaikból.", + "mosaic.dissolved": "A mozaik megszűnt: {name} újra teljes képernyős.", + "mosaic.hostChip": "Mozaik {n}/4", + "mosaic.guestChip": "{name} mozaikjában", + "mosaic.member": "{n}. kontroller → {name}", + "mosaic.chip_title": "Legfeljebb 4 munkamenet osztozik ezen a streamen. Minden kontroller érkezési sorrendben egy munkamenethez kerül: {list}", + "mosaic.stack": "Egymás fölé", + "mosaic.sideBySide": "Egymás mellé", + "mosaic.relayout": "Átrendezés… mindkét munkamenet újracsatlakozik az új felével." } diff --git a/Core/wwwroot/locales/it.json b/Core/wwwroot/locales/it.json index 26cab9c..1a5be6a 100644 --- a/Core/wwwroot/locales/it.json +++ b/Core/wwwroot/locales/it.json @@ -710,5 +710,22 @@ "log.noMatches": "Nessuna riga corrisponde al filtro.", "view.toggle": "Vista sessioni", "view.cards": "Schede", - "view.list": "Elenco" + "view.list": "Elenco", + "mosaic.join": "Unisciti allo stream di un'altra sessione…", + "mosaic.join_disabled": "Non disponibile per una sessione locale o mentre si avvia o si arresta.", + "mosaic.joinTo": "Unisciti a {name} (parte {n} di 4)", + "mosaic.leave": "Esci dal mosaico", + "mosaic.dissolve": "Termina il mosaico", + "mosaic.noHosts": "Al momento nessun'altra sessione sta trasmettendo a un client.", + "mosaic.joining": "{name} si unisce allo stream di {host}… Le sessioni si riconnettono alla dimensione della loro parte.", + "mosaic.joined": "{name} ora è nello stream di {host}.", + "mosaic.left": "{name} è uscita dal mosaico.", + "mosaic.dissolved": "Mosaico terminato: {name} torna a schermo intero.", + "mosaic.hostChip": "Mosaico {n}/4", + "mosaic.guestChip": "Nel mosaico di {name}", + "mosaic.member": "Controller {n} → {name}", + "mosaic.chip_title": "Fino a 4 sessioni condividono questo stream. Ogni controller va a una sessione in ordine di arrivo: {list}", + "mosaic.stack": "Una sopra l'altra", + "mosaic.sideBySide": "Una accanto all'altra", + "mosaic.relayout": "Riorganizzazione… entrambe le sessioni si riconnettono alla loro nuova metà." } diff --git a/Core/wwwroot/locales/ja.json b/Core/wwwroot/locales/ja.json index 07ec56f..def406d 100644 --- a/Core/wwwroot/locales/ja.json +++ b/Core/wwwroot/locales/ja.json @@ -710,5 +710,22 @@ "log.noMatches": "フィルターに一致する行はありません。", "view.toggle": "セッションの表示", "view.cards": "カード", - "view.list": "リスト" + "view.list": "リスト", + "mosaic.join": "別のセッションのストリームに参加…", + "mosaic.join_disabled": "ローカルセッション、または起動中・停止中のセッションでは使用できません。", + "mosaic.joinTo": "{name} に参加(4 分割の {n} 番目)", + "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 セッションで共有されます。各コントローラーは接続順に 1 つのセッションに割り当てられます: {list}", + "mosaic.stack": "上下に並べる", + "mosaic.sideBySide": "左右に並べる", + "mosaic.relayout": "配置を変更しています… 2 つのセッションが新しい半分のサイズで再接続されます。" } diff --git a/Core/wwwroot/locales/ko.json b/Core/wwwroot/locales/ko.json index 315105a..d7781c6 100644 --- a/Core/wwwroot/locales/ko.json +++ b/Core/wwwroot/locales/ko.json @@ -710,5 +710,22 @@ "log.noMatches": "필터와 일치하는 줄이 없습니다.", "view.toggle": "세션 보기", "view.cards": "카드", - "view.list": "목록" + "view.list": "목록", + "mosaic.join": "다른 세션의 스트림에 참여…", + "mosaic.join_disabled": "로컬 세션이거나 시작 또는 중지 중에는 사용할 수 없습니다.", + "mosaic.joinTo": "{name}에 참여 (4개 중 {n}번째 부분)", + "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": "다시 배치하는 중… 두 세션이 새 절반 크기로 다시 연결됩니다." } diff --git a/Core/wwwroot/locales/pl.json b/Core/wwwroot/locales/pl.json index 2721810..cccc1ed 100644 --- a/Core/wwwroot/locales/pl.json +++ b/Core/wwwroot/locales/pl.json @@ -710,5 +710,22 @@ "log.noMatches": "Żadna linia nie pasuje do filtra.", "view.toggle": "Widok sesji", "view.cards": "Karty", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Dołącz do streamu innej sesji…", + "mosaic.join_disabled": "Niedostępne dla sesji lokalnej ani podczas uruchamiania lub zatrzymywania.", + "mosaic.joinTo": "Dołącz do {name} (część {n} z 4)", + "mosaic.leave": "Opuść mozaikę", + "mosaic.dissolve": "Zakończ mozaikę", + "mosaic.noHosts": "Żadna inna sesja nie streamuje teraz do klienta.", + "mosaic.joining": "Dołączanie {name} do streamu {host}… Sesje łączą się ponownie w rozmiarze swojej części.", + "mosaic.joined": "{name} jest teraz w streamie {host}.", + "mosaic.left": "{name} opuściła mozaikę.", + "mosaic.dissolved": "Mozaika zakończona: {name} wraca do pełnego ekranu.", + "mosaic.hostChip": "Mozaika {n}/4", + "mosaic.guestChip": "W mozaice {name}", + "mosaic.member": "Kontroler {n} → {name}", + "mosaic.chip_title": "Ten stream dzieli do 4 sesji. Każdy kontroler trafia do jednej sesji według kolejności podłączenia: {list}", + "mosaic.stack": "Jedna nad drugą", + "mosaic.sideBySide": "Obok siebie", + "mosaic.relayout": "Przestawianie… obie sesje łączą się ponownie w nowej połowie." } diff --git a/Core/wwwroot/locales/pt.json b/Core/wwwroot/locales/pt.json index d024ef3..aedb8ca 100644 --- a/Core/wwwroot/locales/pt.json +++ b/Core/wwwroot/locales/pt.json @@ -710,5 +710,22 @@ "log.noMatches": "Nenhuma linha corresponde ao filtro.", "view.toggle": "Vista das sessões", "view.cards": "Cartões", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Juntar ao stream de outra sessão…", + "mosaic.join_disabled": "Indisponível numa sessão local ou enquanto está a iniciar ou a parar.", + "mosaic.joinTo": "Juntar a {name} (parte {n} de 4)", + "mosaic.leave": "Sair do mosaico", + "mosaic.dissolve": "Terminar o mosaico", + "mosaic.noHosts": "Neste momento nenhuma outra sessão está a transmitir para um cliente.", + "mosaic.joining": "A juntar {name} ao stream de {host}… As sessões voltam a ligar-se com o tamanho da sua parte.", + "mosaic.joined": "{name} está agora no stream de {host}.", + "mosaic.left": "{name} saiu do mosaico.", + "mosaic.dissolved": "Mosaico terminado: {name} volta ao ecrã inteiro.", + "mosaic.hostChip": "Mosaico {n}/4", + "mosaic.guestChip": "No mosaico de {name}", + "mosaic.member": "Comando {n} → {name}", + "mosaic.chip_title": "Até 4 sessões partilham este stream. Cada comando vai para uma sessão por ordem de chegada: {list}", + "mosaic.stack": "Pôr uma por cima da outra", + "mosaic.sideBySide": "Pôr lado a lado", + "mosaic.relayout": "A reorganizar… as duas sessões voltam a ligar-se com a sua nova metade." } diff --git a/Core/wwwroot/locales/pt_BR.json b/Core/wwwroot/locales/pt_BR.json index a96bddd..4ca2e61 100644 --- a/Core/wwwroot/locales/pt_BR.json +++ b/Core/wwwroot/locales/pt_BR.json @@ -710,5 +710,22 @@ "log.noMatches": "Nenhuma linha corresponde ao filtro.", "view.toggle": "Visualização das sessões", "view.cards": "Cartões", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Entrar no stream de outra sessão…", + "mosaic.join_disabled": "Indisponível para uma sessão local ou enquanto ela está iniciando ou parando.", + "mosaic.joinTo": "Entrar em {name} (parte {n} de 4)", + "mosaic.leave": "Sair do mosaico", + "mosaic.dissolve": "Encerrar o mosaico", + "mosaic.noHosts": "No momento nenhuma outra sessão está transmitindo para um cliente.", + "mosaic.joining": "Colocando {name} no stream de {host}… As sessões se reconectam no tamanho da sua parte.", + "mosaic.joined": "{name} agora está no stream de {host}.", + "mosaic.left": "{name} saiu do mosaico.", + "mosaic.dissolved": "Mosaico encerrado: {name} volta para a tela cheia.", + "mosaic.hostChip": "Mosaico {n}/4", + "mosaic.guestChip": "No mosaico de {name}", + "mosaic.member": "Controle {n} → {name}", + "mosaic.chip_title": "Até 4 sessões compartilham este stream. Cada controle vai para uma sessão por ordem de chegada: {list}", + "mosaic.stack": "Colocar uma acima da outra", + "mosaic.sideBySide": "Colocar lado a lado", + "mosaic.relayout": "Reorganizando… as duas sessões se reconectam na nova metade." } diff --git a/Core/wwwroot/locales/ru.json b/Core/wwwroot/locales/ru.json index f2aba4a..12b206d 100644 --- a/Core/wwwroot/locales/ru.json +++ b/Core/wwwroot/locales/ru.json @@ -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": "Перестановка… оба сеанса переподключаются к своей новой половине." } diff --git a/Core/wwwroot/locales/sv.json b/Core/wwwroot/locales/sv.json index a350993..89b626f 100644 --- a/Core/wwwroot/locales/sv.json +++ b/Core/wwwroot/locales/sv.json @@ -710,5 +710,22 @@ "log.noMatches": "Inga rader matchar filtret.", "view.toggle": "Sessionsvy", "view.cards": "Kort", - "view.list": "Lista" + "view.list": "Lista", + "mosaic.join": "Anslut till en annan sessions stream…", + "mosaic.join_disabled": "Inte tillgängligt för en lokal session eller medan den startar eller stoppas.", + "mosaic.joinTo": "Anslut till {name} (del {n} av 4)", + "mosaic.leave": "Lämna mosaiken", + "mosaic.dissolve": "Avsluta mosaiken", + "mosaic.noHosts": "Ingen annan session streamar till en klient just nu.", + "mosaic.joining": "Ansluter {name} till {host}s stream… Sessionerna återansluter i storleken på sin del.", + "mosaic.joined": "{name} är nu i {host}s stream.", + "mosaic.left": "{name} lämnade mosaiken.", + "mosaic.dissolved": "Mosaiken avslutad: {name} är tillbaka i helskärm.", + "mosaic.hostChip": "Mosaik {n}/4", + "mosaic.guestChip": "I {name}s mosaik", + "mosaic.member": "Handkontroll {n} → {name}", + "mosaic.chip_title": "Upp till 4 sessioner delar på den här streamen. Varje handkontroll går till en session i den ordning de ansluter: {list}", + "mosaic.stack": "Placera ovanför varandra", + "mosaic.sideBySide": "Placera sida vid sida", + "mosaic.relayout": "Ordnar om… båda sessionerna återansluter till sin nya halva." } diff --git a/Core/wwwroot/locales/tr.json b/Core/wwwroot/locales/tr.json index 936ef86..1bd0e8d 100644 --- a/Core/wwwroot/locales/tr.json +++ b/Core/wwwroot/locales/tr.json @@ -710,5 +710,22 @@ "log.noMatches": "Filtreyle eşleşen satır yok.", "view.toggle": "Oturum görünümü", "view.cards": "Kartlar", - "view.list": "Liste" + "view.list": "Liste", + "mosaic.join": "Başka bir oturumun yayınına katıl…", + "mosaic.join_disabled": "Yerel oturumda veya oturum başlarken ya da dururken kullanılamaz.", + "mosaic.joinTo": "{name} oturumuna katıl (4 parçadan {n}.)", + "mosaic.leave": "Mozaikten ayrıl", + "mosaic.dissolve": "Mozaiği sonlandır", + "mosaic.noHosts": "Şu anda başka hiçbir oturum bir istemciye yayın yapmıyor.", + "mosaic.joining": "{name}, {host} yayınına katılıyor… Oturumlar kendi parçalarının boyutunda yeniden bağlanır.", + "mosaic.joined": "{name} artık {host} yayınında.", + "mosaic.left": "{name} mozaikten ayrıldı.", + "mosaic.dissolved": "Mozaik sonlandırıldı: {name} yeniden tam ekranda.", + "mosaic.hostChip": "Mozaik {n}/4", + "mosaic.guestChip": "{name} mozaiğinde", + "mosaic.member": "Kumanda {n} → {name}", + "mosaic.chip_title": "Bu yayını en fazla 4 oturum paylaşır. Her kumanda bağlanma sırasına göre bir oturuma gider: {list}", + "mosaic.stack": "Üst üste koy", + "mosaic.sideBySide": "Yan yana koy", + "mosaic.relayout": "Yeniden yerleştiriliyor… iki oturum da yeni yarısıyla yeniden bağlanır." } diff --git a/Core/wwwroot/locales/uk.json b/Core/wwwroot/locales/uk.json index 6c5339a..1e3dcfb 100644 --- a/Core/wwwroot/locales/uk.json +++ b/Core/wwwroot/locales/uk.json @@ -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": "Перестановка… обидва сеанси перепідключаються до своєї нової половини." } diff --git a/Core/wwwroot/locales/vi.json b/Core/wwwroot/locales/vi.json index 291e035..7456132 100644 --- a/Core/wwwroot/locales/vi.json +++ b/Core/wwwroot/locales/vi.json @@ -710,5 +710,22 @@ "log.noMatches": "Không có dòng nào khớp bộ lọc.", "view.toggle": "Chế độ xem phiên", "view.cards": "Thẻ", - "view.list": "Danh sách" + "view.list": "Danh sách", + "mosaic.join": "Tham gia luồng của phiên khác…", + "mosaic.join_disabled": "Không khả dụng cho phiên cục bộ hoặc khi phiên đang khởi động hay dừng.", + "mosaic.joinTo": "Tham gia {name} (phần {n}/4)", + "mosaic.leave": "Rời khỏi khảm", + "mosaic.dissolve": "Kết thúc khảm", + "mosaic.noHosts": "Hiện không có phiên nào khác đang phát tới một máy khách.", + "mosaic.joining": "Đang đưa {name} vào luồng của {host}… Các phiên kết nối lại theo kích thước phần của mình.", + "mosaic.joined": "{name} hiện đã ở trong luồng của {host}.", + "mosaic.left": "{name} đã rời khỏi khảm.", + "mosaic.dissolved": "Đã kết thúc khảm: {name} trở lại toàn màn hình.", + "mosaic.hostChip": "Khảm {n}/4", + "mosaic.guestChip": "Trong khảm của {name}", + "mosaic.member": "Tay cầm {n} → {name}", + "mosaic.chip_title": "Tối đa 4 phiên dùng chung luồng này. Mỗi tay cầm thuộc về một phiên theo thứ tự kết nối: {list}", + "mosaic.stack": "Xếp trên dưới", + "mosaic.sideBySide": "Xếp cạnh nhau", + "mosaic.relayout": "Đang sắp xếp lại… cả hai phiên kết nối lại theo nửa mới." } diff --git a/Core/wwwroot/locales/zh.json b/Core/wwwroot/locales/zh.json index f013530..0d5381c 100644 --- a/Core/wwwroot/locales/zh.json +++ b/Core/wwwroot/locales/zh.json @@ -710,5 +710,22 @@ "log.noMatches": "没有符合筛选条件的行。", "view.toggle": "会话视图", "view.cards": "卡片", - "view.list": "列表" + "view.list": "列表", + "mosaic.join": "加入其他会话的串流…", + "mosaic.join_disabled": "本地会话或正在启动、停止的会话不可用。", + "mosaic.joinTo": "加入 {name}(4 格中的第 {n} 格)", + "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": "正在重新排列… 两个会话将按新的半屏重新连接。" } diff --git a/Core/wwwroot/locales/zh_TW.json b/Core/wwwroot/locales/zh_TW.json index acb27ea..e62cd64 100644 --- a/Core/wwwroot/locales/zh_TW.json +++ b/Core/wwwroot/locales/zh_TW.json @@ -710,5 +710,22 @@ "log.noMatches": "沒有符合篩選條件的行。", "view.toggle": "工作階段檢視", "view.cards": "卡片", - "view.list": "清單" + "view.list": "清單", + "mosaic.join": "加入其他工作階段的串流…", + "mosaic.join_disabled": "本機工作階段或正在啟動、停止的工作階段無法使用。", + "mosaic.joinTo": "加入 {name}(4 格中的第 {n} 格)", + "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": "正在重新排列… 兩個工作階段會依新的半螢幕重新連線。" } diff --git a/Core/wwwroot/style.css b/Core/wwwroot/style.css index f716934..74457fa 100644 --- a/Core/wwwroot/style.css +++ b/Core/wwwroot/style.css @@ -1146,6 +1146,12 @@ section#view-sessions, section#view-seats { animation: viewIn .35s var(--ease); .chip-webrtc .ui-icon { color: var(--accent-2); } .chip-seat .ui-icon { color: var(--amber); } .chip-seat.active { color: var(--text); border-color: rgba(var(--c-amber),.4); background: rgba(var(--c-amber),.08); } +/* Mosaico: mapa 2×2 con la parte de la sesión resaltada. */ +.chip-mosaic.active { color: var(--text); border-color: rgba(var(--c-accent),.4); background: rgba(var(--c-accent),.08); } +.mosaic-map { display: inline-grid; grid-template-columns: repeat(2, 5px); gap: 1px; } +.mosaic-map i { width: 5px; height: 4px; border-radius: 1px; background: rgba(var(--c-ink),.14); } +.mosaic-map i.used { background: rgba(var(--c-accent),.35); } +.mosaic-map i.own { background: var(--accent); } .card-footer { margin-top: 1rem; padding-top: .8rem; border-top: 1px solid rgba(var(--c-ink),.06); align-items: center; } .more-btn { margin-left: auto; } diff --git a/Native/RemoteIdd/FrameConsumer.cpp b/Native/RemoteIdd/FrameConsumer.cpp index d2bca75..9ae0c81 100644 --- a/Native/RemoteIdd/FrameConsumer.cpp +++ b/Native/RemoteIdd/FrameConsumer.cpp @@ -68,6 +68,12 @@ FrameConsumer::Wait FrameConsumer::WaitFrame(DWORD timeoutMs) } } +uint64_t FrameConsumer::LatestFrameId() const +{ + RemoteIddFrameInfo f; + return m_slot && RemoteIddSlotRead(m_slot, &f) ? f.FrameId : 0; +} + bool FrameConsumer::OpenTexture(const RemoteIddFrameInfo& info) { if (m_texture && info.ProducerPid == m_producerPid && info.TextureGeneration == m_generation) diff --git a/Native/RemoteIdd/FrameConsumer.h b/Native/RemoteIdd/FrameConsumer.h index 27143c3..059c2b9 100644 --- a/Native/RemoteIdd/FrameConsumer.h +++ b/Native/RemoteIdd/FrameConsumer.h @@ -33,6 +33,12 @@ namespace osms::ridd bool Open(); Wait WaitFrame(DWORD timeoutMs); + // Evento FrameReady (auto-reset) para esperar a varias sesiones a la vez con + // WaitForMultipleObjects (mosaico de Sunshine). nullptr hasta que Open() acierta. + HANDLE ReadyEvent() const { return m_evReady; } + // FrameId del descriptor más reciente sin tomar la textura; 0 si aún no hay frame. + uint64_t LatestFrameId() const; + // Lee el descriptor más reciente, (re)abre la textura si cambió y toma el keyed // mutex. La textura sigue siendo del consumidor hasta Release(). bool Acquire(DWORD timeoutMs, ID3D11Texture2D** texture, RemoteIddFrameInfo* info); diff --git a/Program.cs b/Program.cs index a2d3022..5be1b49 100644 --- a/Program.cs +++ b/Program.cs @@ -133,11 +133,13 @@ static async Task RunService(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add( @@ -307,6 +309,9 @@ static async Task RunService(string[] args) // ── Seats API (puestos locales) ─────────────────────────────────────────── app.MapSeatApi(); + // ── Mosaic API (hasta cuatro sesiones en un mismo stream) ───────────────── + app.MapMosaicApi(); + // ── Multi-RDP API (TermWrap: varias sesiones RDP simultáneas en Windows cliente) ── app.MapRdpWrapperApi(); diff --git a/README.md b/README.md index 4383c23..e541fde 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Then add the PC in Moonlight (or on your TV) — **Connect** in the dashboard sh - ⚡ **Low latency to TVs** — zero playout delay: 1080p60 from 54 → 38 ms screen to screen - 🎞️ **A true 60 fps** in RDP sessions (instead of 58.8 Hz) - 🧬 **RemoteIdd display** *(experimental)* — our own IddCx driver hands frames to Sunshine as a GPU texture, no screen capture +- 🧩 **Mosaic** *(experimental)* — up to four sessions in one stream (halves or quarters), joined live from the dashboard; controller N goes to session N in order of arrival ([details](docs/mosaico.md)) **Local seats** - 🖱️ Monitor, keyboard, mouse, gamepads, audio and microphone per seat — drag and drop in the dashboard diff --git a/Services/Gamepad/GamepadSessionIsolation.cs b/Services/Gamepad/GamepadSessionIsolation.cs index 9404682..74785d1 100644 --- a/Services/Gamepad/GamepadSessionIsolation.cs +++ b/Services/Gamepad/GamepadSessionIsolation.cs @@ -34,6 +34,11 @@ namespace OpenStreamMS.Services.Gamepad; /// Qué Sunshine creó cada mando se deduce del log: Sunshine escribe /// "Gamepad N will be ..." justo antes de enchufarlo en ViGEmBus. /// +/// +/// Mosaico (): el mando N que crea el Sunshine de la +/// anfitriona va a la sesión N del mosaico por orden de llegada (N del log, 0 = anfitriona). +/// Si el mosaico cambia, los mandos ya creados se reasignan. +/// /// public sealed partial class GamepadSessionIsolation : BackgroundService { @@ -46,11 +51,13 @@ public sealed partial class GamepadSessionIsolation : BackgroundService private static readonly TimeSpan HidHideRetry = TimeSpan.FromSeconds(15); private static readonly TimeSpan PhysicalRescan = TimeSpan.FromSeconds(30); - [GeneratedRegex(@"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]: \w+: Gamepad \d+ will be", RegexOptions.Multiline)] + [GeneratedRegex(@"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]: \w+: Gamepad (\d+) will be", RegexOptions.Multiline)] private static partial Regex GamepadCreatedRegex(); private readonly StreamSessionService _sessions; private readonly Seats.SeatManager _seats; + private readonly Mosaic.MosaicService _mosaic; + private int _mosaicGeneration; private sealed class Pad { @@ -59,6 +66,9 @@ private sealed class Pad public Guid? Session; public string SessionName = ""; public uint WindowsSession; + /// Sesión cuyo Sunshine lo creó y su número en ese Sunshine (-1 si no se sabe). + public Guid? Creator; + public int Index = -1; public bool GaveUp; public readonly HashSet Hidden = new(StringComparer.OrdinalIgnoreCase); public readonly HashSet Restarted = new(StringComparer.OrdinalIgnoreCase); @@ -68,7 +78,7 @@ private sealed class LogCursor { public long Offset; public byte[] Head = []; - public readonly List Pending = []; // creaciones aún sin mando asignado + public readonly List<(DateTime At, int Index)> Pending = []; // creaciones aún sin mando asignado } private readonly Dictionary _pads = new(StringComparer.OrdinalIgnoreCase); @@ -87,10 +97,11 @@ private sealed class LogCursor private int _seatGeneration = -1; private const uint NoSession = 0xFFFFFFFF; - public GamepadSessionIsolation(StreamSessionService sessions, Seats.SeatManager seats) + public GamepadSessionIsolation(StreamSessionService sessions, Seats.SeatManager seats, Mosaic.MosaicService mosaic) { _sessions = sessions; _seats = seats; + _mosaic = mosaic; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -257,6 +268,13 @@ private void Scan() var pad = Track(id, running); if (pad.Session is not null) Hide(pad); } + + int generation = _mosaic.Generation; + if (generation != _mosaicGeneration) + { + _mosaicGeneration = generation; + foreach (var pad in _pads.Values) Retarget(pad); + } } private Pad Track(string padId, @@ -280,18 +298,21 @@ private void TryAssign(Pad pad, IReadOnlyList<(Guid Id, string Name, uint RdpSessionId, string SunshineLog)> running, DateTime now) { Guid? owner = null; - DateTime best = DateTime.MaxValue; + (DateTime At, int Index) best = (DateTime.MaxValue, -1); foreach (var (sessionId, cursor) in _cursors) { - foreach (var t in cursor.Pending) + foreach (var e in cursor.Pending) { - if (t < pad.FirstSeen - MatchBefore || t > pad.FirstSeen + MatchAfter) continue; - if (t < best) { best = t; owner = sessionId; } + if (e.At < pad.FirstSeen - MatchBefore || e.At > pad.FirstSeen + MatchAfter) continue; + if (e.At < best.At) { best = e; owner = sessionId; } } } if (owner is { } o) + { _cursors[o].Pending.Remove(best); + pad.Index = best.Index; + } else if (running.Count == 1) owner = running[0].Id; // sin línea en el log, pero solo hay una sesión else if (now - pad.FirstSeen < AssignTimeout) @@ -305,12 +326,46 @@ private void TryAssign(Pad pad, var session = running.FirstOrDefault(s => s.Id == owner); if (session.Id == Guid.Empty) return; // la sesión del log ya no está en marcha + pad.Creator = owner; + + // Mosaico: el mando N de la anfitriona es de la sesión N. + if (_mosaic.PadTarget(session.Id, pad.Index) is { } target && target != session.Id && + _sessions.Get(target) is { RdpSessionId: > 0 } member) + { + pad.Session = target; + pad.SessionName = member.Name; + pad.WindowsSession = member.RdpSessionId; + Logger.Log($"[Gamepad] Mando {pad.Index} de '{session.Name}' ({pad.InstanceId}) → sesión '{member.Name}' del mosaico (sesión de Windows {member.RdpSessionId})", target); + return; + } pad.Session = owner; pad.SessionName = session.Name; pad.WindowsSession = session.RdpSessionId; Logger.Log($"[Gamepad] Mando {pad.InstanceId} → sesión '{session.Name}' (sesión de Windows {session.RdpSessionId})", owner); } + /// + /// El mosaico cambió: el mando va a la sesión que le toca ahora (o vuelve a la que lo creó) + /// y, si cambia, se oculta de nuevo con la sesión nueva y se reinicia para cerrar los handles + /// que tuviera abiertos la anterior. + /// + private void Retarget(Pad pad) + { + if (pad.Creator is not { } creator || pad.Session is null) return; + var target = _mosaic.PadTarget(creator, pad.Index) ?? creator; + if (_sessions.Get(target) is not { RdpSessionId: > 0 } session) return; + if (target == pad.Session && session.RdpSessionId == pad.WindowsSession) return; + + if (pad.Hidden.Count > 0 && !UpdateJails(add: [], remove: pad.Hidden)) return; + pad.Hidden.Clear(); + pad.Restarted.Clear(); + pad.Session = target; + pad.SessionName = session.Name; + pad.WindowsSession = session.RdpSessionId; + Logger.Log($"[Gamepad] Mando {pad.Index} ({pad.InstanceId}) pasa a la sesión '{session.Name}' (sesión de Windows {session.RdpSessionId}) por un cambio en el mosaico.", target); + Hide(pad); + } + private void ReadGamepadEvents(Guid sessionId, string logPath) { if (!_cursors.TryGetValue(sessionId, out var cur)) @@ -335,9 +390,9 @@ private void ReadGamepadEvents(Guid sessionId, string logPath) { if (DateTime.TryParseExact(m.Groups[1].Value, "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var t) && t >= cutoff) - cur.Pending.Add(t); + cur.Pending.Add((t, int.TryParse(m.Groups[2].ValueSpan, CultureInfo.InvariantCulture, out var n) ? n : -1)); } - cur.Pending.RemoveAll(t => t < cutoff); + cur.Pending.RemoveAll(e => e.At < cutoff); } catch (IOException) { /* Sunshine escribiendo; se reintenta en la siguiente pasada */ } } diff --git a/Services/Localization/Locales/bg.json b/Services/Localization/Locales/bg.json index fc3ca01..56c1d68 100644 --- a/Services/Localization/Locales/bg.json +++ b/Services/Localization/Locales/bg.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Останала папка '{0}' изтрита.", "isolatedUser.errorAddingToGroup": "Не може да се добави '{0}' към група '{1}': грешка {2}.", "isolatedUser.errorRemovingRights": "Не може да се премахнат правата за '{0}': {1}.", + "error.mosaicSelf": "Сесия не може да се присъедини към собствения си стрийм.", + "error.mosaicHostStopped": "Сесията домакин трябва да работи.", + "error.mosaicCapture": "'{0}' не използва заснемане с RemoteIdd, което е нужно за мозайката.", + "error.mosaicSeat": "'{0}' се използва от локална работна станция.", + "error.mosaicNoClient": "В момента никой не гледа '{0}'. Първо свържете клиент.", + "error.mosaicGuestStreaming": "'{0}' има свързан клиент. Първо го изключете.", + "error.mosaicBusy": "'{0}' вече е в мозайка.", + "error.mosaicFull": "Мозайката вече има 4 сесии.", + "error.mosaicGuestStart": "'{0}' не стартира навреме.", + "error.mosaicApply": "Сесиите не можаха да бъдат пренаредени: {0}" "isolatedUser.passwordKept": "Потребителят '{0}' вече съществуваше; паролата му се запазва (Steam и другите запазени влизания остават валидни).", "isolatedUser.passwordStoreFailed": "Паролата на '{0}' не можа да бъде запазена или прочетена в LSA: {1}. Тя ще бъде нулирана при следващото стартиране." } \ No newline at end of file diff --git a/Services/Localization/Locales/cs.json b/Services/Localization/Locales/cs.json index b48b0ed..66bcc88 100644 --- a/Services/Localization/Locales/cs.json +++ b/Services/Localization/Locales/cs.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Zbylá složka '{0}' smazána.", "isolatedUser.errorAddingToGroup": "Nepodařilo se přidat '{0}' do skupiny '{1}': chyba {2}.", "isolatedUser.errorRemovingRights": "Nepodařilo se odebrat práva pro '{0}': {1}.", + "error.mosaicSelf": "Relace se nemůže připojit ke svému vlastnímu streamu.", + "error.mosaicHostStopped": "Hostitelská relace musí běžet.", + "error.mosaicCapture": "'{0}' nepoužívá zachytávání RemoteIdd, které mozaika potřebuje.", + "error.mosaicSeat": "'{0}' používá místní pracovní stanice.", + "error.mosaicNoClient": "Relaci '{0}' teď nikdo nesleduje. Nejdřív připojte klienta.", + "error.mosaicGuestStreaming": "'{0}' má připojeného klienta. Nejdřív ho odpojte.", + "error.mosaicBusy": "'{0}' už je v mozaice.", + "error.mosaicFull": "Mozaika už má 4 relace.", + "error.mosaicGuestStart": "'{0}' se nespustila včas.", + "error.mosaicApply": "Relace se nepodařilo přeskládat: {0}" "isolatedUser.passwordKept": "Uživatel '{0}' již existoval; jeho heslo se zachovává (Steam a další uložená přihlášení zůstávají platná).", "isolatedUser.passwordStoreFailed": "Heslo uživatele '{0}' nelze uložit ani přečíst v LSA: {1}. Při příštím spuštění bude obnoveno." } \ No newline at end of file diff --git a/Services/Localization/Locales/de.json b/Services/Localization/Locales/de.json index cfc711f..0c3042c 100644 --- a/Services/Localization/Locales/de.json +++ b/Services/Localization/Locales/de.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Verwaistes Verzeichnis '{0}' gelöscht.", "isolatedUser.errorAddingToGroup": "'{0}' konnte nicht zur Gruppe '{1}' hinzugefügt werden: Fehler {2}.", "isolatedUser.errorRemovingRights": "Rechte für '{0}' konnten nicht entfernt werden: {1}.", + "error.mosaicSelf": "Eine Sitzung kann nicht ihrem eigenen Stream beitreten.", + "error.mosaicHostStopped": "Die Host-Sitzung muss laufen.", + "error.mosaicCapture": "'{0}' verwendet keine RemoteIdd-Aufnahme, die das Mosaik braucht.", + "error.mosaicSeat": "'{0}' wird von einem lokalen Arbeitsplatz verwendet.", + "error.mosaicNoClient": "Gerade schaut niemand '{0}'. Verbinde zuerst einen Client.", + "error.mosaicGuestStreaming": "Mit '{0}' ist ein Client verbunden. Trenne ihn zuerst.", + "error.mosaicBusy": "'{0}' ist bereits in einem Mosaik.", + "error.mosaicFull": "Das Mosaik hat bereits 4 Sitzungen.", + "error.mosaicGuestStart": "'{0}' ist nicht rechtzeitig gestartet.", + "error.mosaicApply": "Die Sitzungen konnten nicht neu angeordnet werden: {0}" "isolatedUser.passwordKept": "Benutzer '{0}' war bereits vorhanden; sein Kennwort bleibt erhalten (Steam und andere gespeicherte Anmeldungen bleiben gültig).", "isolatedUser.passwordStoreFailed": "Das Kennwort von '{0}' konnte in LSA nicht gespeichert oder gelesen werden: {1}. Es wird beim nächsten Start zurückgesetzt." } \ No newline at end of file diff --git a/Services/Localization/Locales/en.json b/Services/Localization/Locales/en.json index 4521f22..e5cf283 100644 --- a/Services/Localization/Locales/en.json +++ b/Services/Localization/Locales/en.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.", "isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.", "isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.", + "error.mosaicSelf": "A session cannot join its own stream.", + "error.mosaicHostStopped": "The host session must be running.", + "error.mosaicCapture": "'{0}' does not use RemoteIdd capture, which the mosaic needs.", + "error.mosaicSeat": "'{0}' is in use by a local seat.", + "error.mosaicNoClient": "Nobody is streaming '{0}' right now. Connect a client first.", + "error.mosaicGuestStreaming": "'{0}' has a client connected. Disconnect it first.", + "error.mosaicBusy": "'{0}' is already in a mosaic.", + "error.mosaicFull": "The mosaic already has 4 sessions.", + "error.mosaicGuestStart": "'{0}' did not start in time.", + "error.mosaicApply": "The sessions could not be rearranged: {0}" "isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).", "isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start." } \ No newline at end of file diff --git a/Services/Localization/Locales/en_GB.json b/Services/Localization/Locales/en_GB.json index 52ce867..74575d1 100644 --- a/Services/Localization/Locales/en_GB.json +++ b/Services/Localization/Locales/en_GB.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.", "isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.", "isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.", + "error.mosaicSelf": "A session cannot join its own stream.", + "error.mosaicHostStopped": "The host session must be running.", + "error.mosaicCapture": "'{0}' does not use RemoteIdd capture, which the mosaic needs.", + "error.mosaicSeat": "'{0}' is in use by a local seat.", + "error.mosaicNoClient": "Nobody is streaming '{0}' right now. Connect a client first.", + "error.mosaicGuestStreaming": "'{0}' has a client connected. Disconnect it first.", + "error.mosaicBusy": "'{0}' is already in a mosaic.", + "error.mosaicFull": "The mosaic already has 4 sessions.", + "error.mosaicGuestStart": "'{0}' did not start in time.", + "error.mosaicApply": "The sessions could not be rearranged: {0}" "isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).", "isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start." } \ No newline at end of file diff --git a/Services/Localization/Locales/en_US.json b/Services/Localization/Locales/en_US.json index db4f032..dab7e52 100644 --- a/Services/Localization/Locales/en_US.json +++ b/Services/Localization/Locales/en_US.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Orphan folder '{0}' deleted.", "isolatedUser.errorAddingToGroup": "Could not add '{0}' to group '{1}': error {2}.", "isolatedUser.errorRemovingRights": "Could not remove rights for '{0}': {1}.", + "error.mosaicSelf": "A session cannot join its own stream.", + "error.mosaicHostStopped": "The host session must be running.", + "error.mosaicCapture": "'{0}' does not use RemoteIdd capture, which the mosaic needs.", + "error.mosaicSeat": "'{0}' is in use by a local seat.", + "error.mosaicNoClient": "Nobody is streaming '{0}' right now. Connect a client first.", + "error.mosaicGuestStreaming": "'{0}' has a client connected. Disconnect it first.", + "error.mosaicBusy": "'{0}' is already in a mosaic.", + "error.mosaicFull": "The mosaic already has 4 sessions.", + "error.mosaicGuestStart": "'{0}' did not start in time.", + "error.mosaicApply": "The sessions could not be rearranged: {0}" "isolatedUser.passwordKept": "User '{0}' already existed; its password is kept (Steam and other saved logins stay valid).", "isolatedUser.passwordStoreFailed": "Could not save or read the password of '{0}' in LSA: {1}. It will be reset on the next start." } \ No newline at end of file diff --git a/Services/Localization/Locales/es.json b/Services/Localization/Locales/es.json index 513c10b..9dbec10 100644 --- a/Services/Localization/Locales/es.json +++ b/Services/Localization/Locales/es.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Carpeta huérfana '{0}' eliminada.", "isolatedUser.errorAddingToGroup": "No se pudo añadir '{0}' al grupo '{1}': error {2}.", "isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}.", + "error.mosaicSelf": "Una sesión no puede unirse a su propio stream.", + "error.mosaicHostStopped": "La sesión anfitriona tiene que estar en marcha.", + "error.mosaicCapture": "'{0}' no usa la captura RemoteIdd, que el mosaico necesita.", + "error.mosaicSeat": "'{0}' la está usando un puesto local.", + "error.mosaicNoClient": "Ahora mismo nadie está viendo '{0}'. Conecta antes un cliente.", + "error.mosaicGuestStreaming": "'{0}' tiene un cliente conectado. Desconéctalo antes.", + "error.mosaicBusy": "'{0}' ya está en un mosaico.", + "error.mosaicFull": "El mosaico ya tiene 4 sesiones.", + "error.mosaicGuestStart": "'{0}' no arrancó a tiempo.", + "error.mosaicApply": "No se pudieron recolocar las sesiones: {0}" "isolatedUser.passwordKept": "Usuario '{0}' ya existía; se conserva su contraseña (Steam y otros inicios de sesión guardados siguen valiendo).", "isolatedUser.passwordStoreFailed": "No se pudo guardar o leer la contraseña de '{0}' en LSA: {1}. Se restablecerá en el siguiente arranque." } \ No newline at end of file diff --git a/Services/Localization/Locales/fr.json b/Services/Localization/Locales/fr.json index 1053db0..e0abf33 100644 --- a/Services/Localization/Locales/fr.json +++ b/Services/Localization/Locales/fr.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Carpeta huérfana '{0}' eliminada.", "isolatedUser.errorAddingToGroup": "No se pudo añadir '{0}' al grupo '{1}': error {2}.", "isolatedUser.errorRemovingRights": "No se pudieron quitar los derechos de '{0}': {1}.", + "error.mosaicSelf": "Une session ne peut pas rejoindre son propre stream.", + "error.mosaicHostStopped": "La session hôte doit être en cours d'exécution.", + "error.mosaicCapture": "'{0}' n'utilise pas la capture RemoteIdd, nécessaire à la mosaïque.", + "error.mosaicSeat": "'{0}' est utilisée par un poste local.", + "error.mosaicNoClient": "Personne ne regarde '{0}' pour le moment. Connectez d'abord un client.", + "error.mosaicGuestStreaming": "'{0}' a un client connecté. Déconnectez-le d'abord.", + "error.mosaicBusy": "'{0}' est déjà dans une mosaïque.", + "error.mosaicFull": "La mosaïque compte déjà 4 sessions.", + "error.mosaicGuestStart": "'{0}' n'a pas démarré à temps.", + "error.mosaicApply": "Impossible de réorganiser les sessions : {0}" "isolatedUser.passwordKept": "L'utilisateur '{0}' existait déjà ; son mot de passe est conservé (Steam et les autres connexions enregistrées restent valides).", "isolatedUser.passwordStoreFailed": "Impossible d'enregistrer ou de lire le mot de passe de '{0}' dans LSA : {1}. Il sera réinitialisé au prochain démarrage." } \ No newline at end of file diff --git a/Services/Localization/Locales/hu.json b/Services/Localization/Locales/hu.json index fb9d3dc..146788a 100644 --- a/Services/Localization/Locales/hu.json +++ b/Services/Localization/Locales/hu.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Ártatlan mappa '{0}' törölve.", "isolatedUser.errorAddingToGroup": "A '{0}' nem adható hozzá a '{1}' csoporthoz: hiba {2}.", "isolatedUser.errorRemovingRights": "A '{0}' jogai nem vonhatók ki: {1}.", + "error.mosaicSelf": "Egy munkamenet nem csatlakozhat a saját streamjéhez.", + "error.mosaicHostStopped": "A gazda munkamenetnek futnia kell.", + "error.mosaicCapture": "'{0}' nem RemoteIdd rögzítést használ, pedig a mozaikhoz az kell.", + "error.mosaicSeat": "'{0}' munkamenetet egy helyi munkaállomás használja.", + "error.mosaicNoClient": "Jelenleg senki sem nézi ezt: '{0}'. Előbb csatlakoztasson egy klienst.", + "error.mosaicGuestStreaming": "'{0}' munkamenethez kliens csatlakozik. Előbb bontsa a kapcsolatot.", + "error.mosaicBusy": "'{0}' már egy mozaikban van.", + "error.mosaicFull": "A mozaikban már 4 munkamenet van.", + "error.mosaicGuestStart": "'{0}' nem indult el időben.", + "error.mosaicApply": "Nem sikerült átrendezni a munkameneteket: {0}" "isolatedUser.passwordKept": "A(z) '{0}' felhasználó már létezett; a jelszava megmarad (a Steam és más mentett bejelentkezések érvényesek maradnak).", "isolatedUser.passwordStoreFailed": "Nem sikerült menteni vagy olvasni a(z) '{0}' jelszavát az LSA-ban: {1}. A következő indításkor alaphelyzetbe áll." } \ No newline at end of file diff --git a/Services/Localization/Locales/it.json b/Services/Localization/Locales/it.json index e729f48..2f5f6e1 100644 --- a/Services/Localization/Locales/it.json +++ b/Services/Localization/Locales/it.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Cartella orfana '{0}' eliminata.", "isolatedUser.errorAddingToGroup": "Impossibile aggiungere '{0}' al gruppo '{1}': errore {2}.", "isolatedUser.errorRemovingRights": "Impossibile rimuovere i diritti per '{0}': {1}.", + "error.mosaicSelf": "Una sessione non può unirsi al proprio stream.", + "error.mosaicHostStopped": "La sessione host deve essere in esecuzione.", + "error.mosaicCapture": "'{0}' non usa la cattura RemoteIdd, necessaria per il mosaico.", + "error.mosaicSeat": "'{0}' è in uso da una postazione locale.", + "error.mosaicNoClient": "Al momento nessuno sta guardando '{0}'. Collega prima un client.", + "error.mosaicGuestStreaming": "'{0}' ha un client connesso. Disconnettilo prima.", + "error.mosaicBusy": "'{0}' è già in un mosaico.", + "error.mosaicFull": "Il mosaico ha già 4 sessioni.", + "error.mosaicGuestStart": "'{0}' non si è avviata in tempo.", + "error.mosaicApply": "Impossibile riorganizzare le sessioni: {0}" "isolatedUser.passwordKept": "L'utente '{0}' esisteva già; la sua password viene mantenuta (Steam e gli altri accessi salvati restano validi).", "isolatedUser.passwordStoreFailed": "Impossibile salvare o leggere la password di '{0}' in LSA: {1}. Verrà reimpostata al prossimo avvio." } \ No newline at end of file diff --git a/Services/Localization/Locales/ja.json b/Services/Localization/Locales/ja.json index cfa6bd3..ee9c012 100644 --- a/Services/Localization/Locales/ja.json +++ b/Services/Localization/Locales/ja.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "孤立フォルダー '{0}' を削除しました。", "isolatedUser.errorAddingToGroup": "'{0}' をグループ '{1}' に追加できません: エラー {2}。", "isolatedUser.errorRemovingRights": "'{0}' の権利を削除できません: {1}。", + "error.mosaicSelf": "セッションは自分自身のストリームには参加できません。", + "error.mosaicHostStopped": "ホストセッションが実行中である必要があります。", + "error.mosaicCapture": "'{0}' はモザイクに必要な RemoteIdd キャプチャを使用していません。", + "error.mosaicSeat": "'{0}' はローカル席で使用中です。", + "error.mosaicNoClient": "現在 '{0}' を視聴しているクライアントはありません。先にクライアントを接続してください。", + "error.mosaicGuestStreaming": "'{0}' にはクライアントが接続されています。先に切断してください。", + "error.mosaicBusy": "'{0}' はすでにモザイクに参加しています。", + "error.mosaicFull": "モザイクにはすでに 4 セッションあります。", + "error.mosaicGuestStart": "'{0}' が時間内に起動しませんでした。", + "error.mosaicApply": "セッションを再配置できませんでした: {0}" "isolatedUser.passwordKept": "ユーザー '{0}' は既に存在していました。パスワードはそのまま保持されます (Steam などの保存済みログインは引き続き有効です)。", "isolatedUser.passwordStoreFailed": "'{0}' のパスワードを LSA に保存または読み取りできませんでした: {1}。次回の起動時にリセットされます。" } \ No newline at end of file diff --git a/Services/Localization/Locales/ko.json b/Services/Localization/Locales/ko.json index da76c40..ce28e6b 100644 --- a/Services/Localization/Locales/ko.json +++ b/Services/Localization/Locales/ko.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "고아 폴더 '{0}'이(가) 삭제되었습니다.", "isolatedUser.errorAddingToGroup": "'{0}'을(를) 그룹 '{1}'에 추가할 수 없습니다: 오류 {2}.", "isolatedUser.errorRemovingRights": "'{0}'의 권한을 제거할 수 없습니다: {1}.", + "error.mosaicSelf": "세션은 자신의 스트림에 참여할 수 없습니다.", + "error.mosaicHostStopped": "호스트 세션이 실행 중이어야 합니다.", + "error.mosaicCapture": "'{0}'은(는) 모자이크에 필요한 RemoteIdd 캡처를 사용하지 않습니다.", + "error.mosaicSeat": "'{0}'은(는) 로컬 좌석에서 사용 중입니다.", + "error.mosaicNoClient": "지금 '{0}'을(를) 보고 있는 사람이 없습니다. 먼저 클라이언트를 연결하세요.", + "error.mosaicGuestStreaming": "'{0}'에 클라이언트가 연결되어 있습니다. 먼저 연결을 끊으세요.", + "error.mosaicBusy": "'{0}'은(는) 이미 모자이크에 있습니다.", + "error.mosaicFull": "모자이크에 이미 세션이 4개 있습니다.", + "error.mosaicGuestStart": "'{0}'이(가) 제시간에 시작되지 않았습니다.", + "error.mosaicApply": "세션을 다시 배치하지 못했습니다: {0}" "isolatedUser.passwordKept": "사용자 '{0}'이(가) 이미 있습니다. 암호가 유지됩니다(Steam 및 기타 저장된 로그인은 계속 유효합니다).", "isolatedUser.passwordStoreFailed": "LSA에서 '{0}'의 암호를 저장하거나 읽을 수 없습니다: {1}. 다음 시작 시 재설정됩니다." } \ No newline at end of file diff --git a/Services/Localization/Locales/pl.json b/Services/Localization/Locales/pl.json index a1eb90c..5453d4d 100644 --- a/Services/Localization/Locales/pl.json +++ b/Services/Localization/Locales/pl.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Pozostali folder '{0}' usunięty.", "isolatedUser.errorAddingToGroup": "Nie można dodać '{0}' do grupy '{1}': błąd {2}.", "isolatedUser.errorRemovingRights": "Nie można usunąć uprawnień dla '{0}': {1}.", + "error.mosaicSelf": "Sesja nie może dołączyć do własnego streamu.", + "error.mosaicHostStopped": "Sesja gospodarza musi być uruchomiona.", + "error.mosaicCapture": "'{0}' nie używa przechwytywania RemoteIdd, którego wymaga mozaika.", + "error.mosaicSeat": "'{0}' jest używana przez stanowisko lokalne.", + "error.mosaicNoClient": "Nikt teraz nie ogląda '{0}'. Najpierw podłącz klienta.", + "error.mosaicGuestStreaming": "'{0}' ma podłączonego klienta. Najpierw go rozłącz.", + "error.mosaicBusy": "'{0}' jest już w mozaice.", + "error.mosaicFull": "Mozaika ma już 4 sesje.", + "error.mosaicGuestStart": "'{0}' nie uruchomiła się na czas.", + "error.mosaicApply": "Nie udało się przestawić sesji: {0}" "isolatedUser.passwordKept": "Użytkownik '{0}' już istniał; jego hasło zostaje zachowane (Steam i inne zapisane logowania pozostają ważne).", "isolatedUser.passwordStoreFailed": "Nie udało się zapisać ani odczytać hasła '{0}' w LSA: {1}. Zostanie zresetowane przy następnym uruchomieniu." } \ No newline at end of file diff --git a/Services/Localization/Locales/pt.json b/Services/Localization/Locales/pt.json index 65091cb..5ea6066 100644 --- a/Services/Localization/Locales/pt.json +++ b/Services/Localization/Locales/pt.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Pasta órfã '{0}' eliminada.", "isolatedUser.errorAddingToGroup": "Não foi possível adicionar '{0}' ao grupo '{1}': erro {2}.", "isolatedUser.errorRemovingRights": "Não foi possível remover os direitos de '{0}': {1}.", + "error.mosaicSelf": "Uma sessão não se pode juntar ao seu próprio stream.", + "error.mosaicHostStopped": "A sessão anfitriã tem de estar em execução.", + "error.mosaicCapture": "'{0}' não usa a captura RemoteIdd, de que o mosaico precisa.", + "error.mosaicSeat": "'{0}' está a ser usada por um posto local.", + "error.mosaicNoClient": "Neste momento ninguém está a ver '{0}'. Ligue primeiro um cliente.", + "error.mosaicGuestStreaming": "'{0}' tem um cliente ligado. Desligue-o primeiro.", + "error.mosaicBusy": "'{0}' já está num mosaico.", + "error.mosaicFull": "O mosaico já tem 4 sessões.", + "error.mosaicGuestStart": "'{0}' não iniciou a tempo.", + "error.mosaicApply": "Não foi possível reorganizar as sessões: {0}" "isolatedUser.passwordKept": "O utilizador '{0}' já existia; a sua palavra-passe é mantida (o Steam e outros inícios de sessão guardados continuam válidos).", "isolatedUser.passwordStoreFailed": "Não foi possível guardar ou ler a palavra-passe de '{0}' no LSA: {1}. Será reposta no próximo arranque." } \ No newline at end of file diff --git a/Services/Localization/Locales/pt_BR.json b/Services/Localization/Locales/pt_BR.json index 6830fc4..7587c2a 100644 --- a/Services/Localization/Locales/pt_BR.json +++ b/Services/Localization/Locales/pt_BR.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Pasta órfã '{0}' excluída.", "isolatedUser.errorAddingToGroup": "Não foi possível adicionar '{0}' ao grupo '{1}': erro {2}.", "isolatedUser.errorRemovingRights": "Não foi possível remover os direitos de '{0}': {1}.", + "error.mosaicSelf": "Uma sessão não pode entrar no próprio stream.", + "error.mosaicHostStopped": "A sessão anfitriã precisa estar em execução.", + "error.mosaicCapture": "'{0}' não usa a captura RemoteIdd, que o mosaico exige.", + "error.mosaicSeat": "'{0}' está sendo usada por uma estação local.", + "error.mosaicNoClient": "No momento ninguém está assistindo '{0}'. Conecte um cliente primeiro.", + "error.mosaicGuestStreaming": "'{0}' tem um cliente conectado. Desconecte-o primeiro.", + "error.mosaicBusy": "'{0}' já está em um mosaico.", + "error.mosaicFull": "O mosaico já tem 4 sessões.", + "error.mosaicGuestStart": "'{0}' não iniciou a tempo.", + "error.mosaicApply": "Não foi possível reorganizar as sessões: {0}" "isolatedUser.passwordKept": "O usuário '{0}' já existia; a senha dele é mantida (o Steam e outros logins salvos continuam válidos).", "isolatedUser.passwordStoreFailed": "Não foi possível salvar ou ler a senha de '{0}' no LSA: {1}. Ela será redefinida na próxima inicialização." } \ No newline at end of file diff --git a/Services/Localization/Locales/ru.json b/Services/Localization/Locales/ru.json index 84334b5..df935ed 100644 --- a/Services/Localization/Locales/ru.json +++ b/Services/Localization/Locales/ru.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Оставшаяся папка '{0}' удалена.", "isolatedUser.errorAddingToGroup": "Не удалось добавить '{0}' в группу '{1}': ошибка {2}.", "isolatedUser.errorRemovingRights": "Не удалось удалить права для '{0}': {1}.", + "error.mosaicSelf": "Сеанс не может присоединиться к собственной трансляции.", + "error.mosaicHostStopped": "Сеанс-хост должен быть запущен.", + "error.mosaicCapture": "'{0}' не использует захват RemoteIdd, который нужен мозаике.", + "error.mosaicSeat": "'{0}' используется локальным рабочим местом.", + "error.mosaicNoClient": "Сейчас '{0}' никто не смотрит. Сначала подключите клиент.", + "error.mosaicGuestStreaming": "К '{0}' подключён клиент. Сначала отключите его.", + "error.mosaicBusy": "'{0}' уже в мозаике.", + "error.mosaicFull": "В мозаике уже 4 сеанса.", + "error.mosaicGuestStart": "'{0}' не запустился вовремя.", + "error.mosaicApply": "Не удалось переставить сеансы: {0}" "isolatedUser.passwordKept": "Пользователь '{0}' уже существовал; его пароль сохраняется (Steam и другие сохранённые входы остаются действительными).", "isolatedUser.passwordStoreFailed": "Не удалось сохранить или прочитать пароль '{0}' в LSA: {1}. Он будет сброшен при следующем запуске." } \ No newline at end of file diff --git a/Services/Localization/Locales/sv.json b/Services/Localization/Locales/sv.json index 53607c7..39b0e29 100644 --- a/Services/Localization/Locales/sv.json +++ b/Services/Localization/Locales/sv.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Överbliven mapp '{0}' borttagen.", "isolatedUser.errorAddingToGroup": "Kunde inte lägga till '{0}' i grupp '{1}': fel {2}.", "isolatedUser.errorRemovingRights": "Kunde inte ta bort rättigheter för '{0}': {1}.", + "error.mosaicSelf": "En session kan inte ansluta till sin egen stream.", + "error.mosaicHostStopped": "Värdsessionen måste vara igång.", + "error.mosaicCapture": "'{0}' använder inte RemoteIdd-fångst, som mosaiken behöver.", + "error.mosaicSeat": "'{0}' används av en lokal arbetsplats.", + "error.mosaicNoClient": "Ingen tittar på '{0}' just nu. Anslut en klient först.", + "error.mosaicGuestStreaming": "'{0}' har en ansluten klient. Koppla från den först.", + "error.mosaicBusy": "'{0}' är redan i en mosaik.", + "error.mosaicFull": "Mosaiken har redan 4 sessioner.", + "error.mosaicGuestStart": "'{0}' startade inte i tid.", + "error.mosaicApply": "Sessionerna kunde inte ordnas om: {0}" "isolatedUser.passwordKept": "Användaren '{0}' fanns redan; lösenordet behålls (Steam och andra sparade inloggningar fortsätter att gälla).", "isolatedUser.passwordStoreFailed": "Det gick inte att spara eller läsa lösenordet för '{0}' i LSA: {1}. Det återställs vid nästa start." } \ No newline at end of file diff --git a/Services/Localization/Locales/tr.json b/Services/Localization/Locales/tr.json index 4ff4bb1..c1b99b3 100644 --- a/Services/Localization/Locales/tr.json +++ b/Services/Localization/Locales/tr.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Kalıntı klasör '{0}' silindi.", "isolatedUser.errorAddingToGroup": "'{0}' '{1}' grubuna eklenemedi: hata {2}.", "isolatedUser.errorRemovingRights": "'{0}' hakları kaldırılamadı: {1}.", + "error.mosaicSelf": "Bir oturum kendi yayınına katılamaz.", + "error.mosaicHostStopped": "Ana oturumun çalışıyor olması gerekir.", + "error.mosaicCapture": "'{0}', mozaiğin ihtiyaç duyduğu RemoteIdd yakalamayı kullanmıyor.", + "error.mosaicSeat": "'{0}' yerel bir istasyon tarafından kullanılıyor.", + "error.mosaicNoClient": "Şu anda kimse '{0}' oturumunu izlemiyor. Önce bir istemci bağlayın.", + "error.mosaicGuestStreaming": "'{0}' oturumuna bağlı bir istemci var. Önce bağlantısını kesin.", + "error.mosaicBusy": "'{0}' zaten bir mozaikte.", + "error.mosaicFull": "Mozaikte zaten 4 oturum var.", + "error.mosaicGuestStart": "'{0}' zamanında başlamadı.", + "error.mosaicApply": "Oturumlar yeniden yerleştirilemedi: {0}" "isolatedUser.passwordKept": "'{0}' kullanıcısı zaten vardı; parolası korunuyor (Steam ve diğer kayıtlı oturum açmalar geçerli kalır).", "isolatedUser.passwordStoreFailed": "'{0}' parolası LSA'ya kaydedilemedi veya okunamadı: {1}. Bir sonraki başlatmada sıfırlanacak." } \ No newline at end of file diff --git a/Services/Localization/Locales/uk.json b/Services/Localization/Locales/uk.json index db545c4..d852d82 100644 --- a/Services/Localization/Locales/uk.json +++ b/Services/Localization/Locales/uk.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Залишкова папка '{0}' видалена.", "isolatedUser.errorAddingToGroup": "Не вдалося додати '{0}' до групи '{1}': помилка {2}.", "isolatedUser.errorRemovingRights": "Не вдалося видалити права для '{0}': {1}.", + "error.mosaicSelf": "Сеанс не може приєднатися до власної трансляції.", + "error.mosaicHostStopped": "Сеанс-хост має бути запущений.", + "error.mosaicCapture": "'{0}' не використовує захоплення RemoteIdd, потрібне для мозаїки.", + "error.mosaicSeat": "'{0}' використовується локальним робочим місцем.", + "error.mosaicNoClient": "Зараз '{0}' ніхто не дивиться. Спершу підключіть клієнт.", + "error.mosaicGuestStreaming": "До '{0}' підключено клієнт. Спершу відключіть його.", + "error.mosaicBusy": "'{0}' вже в мозаїці.", + "error.mosaicFull": "У мозаїці вже 4 сеанси.", + "error.mosaicGuestStart": "'{0}' не запустився вчасно.", + "error.mosaicApply": "Не вдалося переставити сеанси: {0}" "isolatedUser.passwordKept": "Користувач '{0}' уже існував; його пароль зберігається (Steam та інші збережені входи залишаються дійсними).", "isolatedUser.passwordStoreFailed": "Не вдалося зберегти або прочитати пароль '{0}' в LSA: {1}. Його буде скинуто під час наступного запуску." } \ No newline at end of file diff --git a/Services/Localization/Locales/vi.json b/Services/Localization/Locales/vi.json index 1eab2df..ab60b64 100644 --- a/Services/Localization/Locales/vi.json +++ b/Services/Localization/Locales/vi.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "Thư mục mồ côi '{0}' đã bị xóa.", "isolatedUser.errorAddingToGroup": "Không thể thêm '{0}' vào nhóm '{1}': lỗi {2}.", "isolatedUser.errorRemovingRights": "Không thể xóa quyền của '{0}': {1}.", + "error.mosaicSelf": "Một phiên không thể tham gia luồng của chính nó.", + "error.mosaicHostStopped": "Phiên chủ phải đang chạy.", + "error.mosaicCapture": "'{0}' không dùng chế độ chụp RemoteIdd mà khảm cần.", + "error.mosaicSeat": "'{0}' đang được một chỗ ngồi cục bộ sử dụng.", + "error.mosaicNoClient": "Hiện không có ai xem '{0}'. Hãy kết nối một máy khách trước.", + "error.mosaicGuestStreaming": "'{0}' đang có máy khách kết nối. Hãy ngắt kết nối trước.", + "error.mosaicBusy": "'{0}' đã ở trong một khảm.", + "error.mosaicFull": "Khảm đã có 4 phiên.", + "error.mosaicGuestStart": "'{0}' không khởi động kịp thời.", + "error.mosaicApply": "Không thể sắp xếp lại các phiên: {0}" "isolatedUser.passwordKept": "Người dùng '{0}' đã tồn tại; mật khẩu được giữ nguyên (Steam và các thông tin đăng nhập đã lưu khác vẫn hợp lệ).", "isolatedUser.passwordStoreFailed": "Không thể lưu hoặc đọc mật khẩu của '{0}' trong LSA: {1}. Mật khẩu sẽ được đặt lại ở lần khởi động tiếp theo." } \ No newline at end of file diff --git a/Services/Localization/Locales/zh.json b/Services/Localization/Locales/zh.json index 8f2e3e0..b55f922 100644 --- a/Services/Localization/Locales/zh.json +++ b/Services/Localization/Locales/zh.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "孤立文件夹 '{0}' 已删除。", "isolatedUser.errorAddingToGroup": "无法将 '{0}' 添加到组 '{1}': 错误 {2}。", "isolatedUser.errorRemovingRights": "无法移除 '{0}' 的权限: {1}。", + "error.mosaicSelf": "会话不能加入自己的串流。", + "error.mosaicHostStopped": "主会话必须处于运行状态。", + "error.mosaicCapture": "'{0}' 未使用拼接画面所需的 RemoteIdd 捕获。", + "error.mosaicSeat": "'{0}' 正被本地座位使用。", + "error.mosaicNoClient": "当前没有人在观看 '{0}'。请先连接一个客户端。", + "error.mosaicGuestStreaming": "'{0}' 已连接客户端。请先断开连接。", + "error.mosaicBusy": "'{0}' 已在拼接画面中。", + "error.mosaicFull": "拼接画面已有 4 个会话。", + "error.mosaicGuestStart": "'{0}' 未能及时启动。", + "error.mosaicApply": "无法重新排列会话:{0}" "isolatedUser.passwordKept": "用户 '{0}' 已存在;保留其密码(Steam 和其他已保存的登录仍然有效)。", "isolatedUser.passwordStoreFailed": "无法在 LSA 中保存或读取 '{0}' 的密码:{1}。下次启动时将重置。" } \ No newline at end of file diff --git a/Services/Localization/Locales/zh_TW.json b/Services/Localization/Locales/zh_TW.json index f6bb8de..2c37e3b 100644 --- a/Services/Localization/Locales/zh_TW.json +++ b/Services/Localization/Locales/zh_TW.json @@ -83,6 +83,16 @@ "isolatedUser.orphanFolderDeleted": "孤立資料夾 '{0}' 已刪除。", "isolatedUser.errorAddingToGroup": "無法將 '{0}' 新增到群組 '{1}': 錯誤 {2}。", "isolatedUser.errorRemovingRights": "無法移除 '{0}' 的權限: {1}。", + "error.mosaicSelf": "工作階段無法加入自己的串流。", + "error.mosaicHostStopped": "主工作階段必須正在執行。", + "error.mosaicCapture": "'{0}' 未使用拼接畫面所需的 RemoteIdd 擷取。", + "error.mosaicSeat": "'{0}' 正由本機座位使用。", + "error.mosaicNoClient": "目前沒有人在觀看 '{0}'。請先連線一個用戶端。", + "error.mosaicGuestStreaming": "'{0}' 已連線用戶端。請先中斷連線。", + "error.mosaicBusy": "'{0}' 已在拼接畫面中。", + "error.mosaicFull": "拼接畫面已有 4 個工作階段。", + "error.mosaicGuestStart": "'{0}' 未能及時啟動。", + "error.mosaicApply": "無法重新排列工作階段:{0}" "isolatedUser.passwordKept": "使用者 '{0}' 已存在;保留其密碼(Steam 和其他已儲存的登入仍然有效)。", "isolatedUser.passwordStoreFailed": "無法在 LSA 中儲存或讀取 '{0}' 的密碼:{1}。下次啟動時將重設。" } \ No newline at end of file diff --git a/Services/Mosaic/MosaicLayout.cs b/Services/Mosaic/MosaicLayout.cs new file mode 100644 index 0000000..17b21b2 --- /dev/null +++ b/Services/Mosaic/MosaicLayout.cs @@ -0,0 +1,54 @@ +using System.Text; +using OpenStreamMS.Services.Seats; + +namespace OpenStreamMS.Services.Mosaic; + +/// +/// Reparto del mosaico: qué parte de la cuadrícula de 2×2 ocupa cada sesión según cuántas +/// haya, qué tamaño tiene esa parte y el fichero osms-mosaic.txt que lee Sunshine. +/// Las partes son las mismas que las de la pantalla dividida de los puestos +/// () y se calculan igual (). +/// +public static class MosaicLayout +{ + /// Sesiones como máximo en un mosaico (la anfitriona incluida). + public const int MaxMembers = 4; + + /// Nombre del fichero de control, junto a sunshine.conf de la anfitriona. + public const string FileName = "osms-mosaic.txt"; + + /// + /// Partes por orden de llegada: 1 sesión, pantalla entera; 2, mitades izquierda/derecha + /// (o arriba/abajo con ); 3 o 4, cuartos (con 3, el de abajo a la + /// derecha queda en negro). + /// + public static SeatScreenArea[] Areas(int count, bool stacked = false) => count switch + { + <= 1 => [SeatScreenArea.Full], + 2 => stacked ? [SeatScreenArea.Top, SeatScreenArea.Bottom] : [SeatScreenArea.Left, SeatScreenArea.Right], + 3 => [SeatScreenArea.TopLeft, SeatScreenArea.TopRight, SeatScreenArea.BottomLeft], + _ => [SeatScreenArea.TopLeft, SeatScreenArea.TopRight, SeatScreenArea.BottomLeft, SeatScreenArea.BottomRight], + }; + + /// Tamaño de una parte en un lienzo de ×. + public static (int Width, int Height) PartSize(SeatScreenArea area, int width, int height) + { + var (_, _, w, h) = new SeatMonitor { Area = area }.Region(0, 0, width, height); + return (w, h); + } + + /// + /// Contenido de osms-mosaic.txt: el lienzo y una línea por sesión + /// ("<id de sesión de Windows> <parte>"), en orden de llegada. + /// + public static string Render(int canvasWidth, int canvasHeight, IReadOnlyList windowsSessions, bool stacked = false) + { + var areas = Areas(windowsSessions.Count, stacked); + var sb = new StringBuilder(); + sb.Append("# OpenStreamMS mosaic: \n"); + sb.Append($"canvas {canvasWidth} {canvasHeight}\n"); + for (int i = 0; i < windowsSessions.Count && i < areas.Length; i++) + sb.Append($"{windowsSessions[i]} {areas[i]}\n"); + return sb.ToString(); + } +} diff --git a/Services/Mosaic/MosaicService.cs b/Services/Mosaic/MosaicService.cs new file mode 100644 index 0000000..6f50724 --- /dev/null +++ b/Services/Mosaic/MosaicService.cs @@ -0,0 +1,405 @@ +using Microsoft.Extensions.Hosting; +using OpenStreamMS.Core.Api; +using OpenStreamMS.Services.Localization; +using OpenStreamMS.Services.Seats; + +namespace OpenStreamMS.Services.Mosaic; + +/// +/// Mosaico: una sesión que se está retransmitiendo (la anfitriona) a la que se unen en caliente +/// hasta otras tres. El cliente de Moonlight/WebRTC ve las cuatro en una cuadrícula de 2×2. +/// +/// Vídeo: el Sunshine de la anfitriona lee los fotogramas RemoteIdd de todas y los compone +/// (osms-mosaic.txt junto a su sunshine.conf, ver ). +/// Cada sesión se reconecta al tamaño de su parte; el lienzo es la resolución de la anfitriona. +/// Sunshine de las invitadas: en pausa mientras están en el mosaico (como un puesto exclusivo). +/// Mandos: el mando N que crea el Sunshine de la anfitriona va a la sesión N por orden de +/// llegada (, lo aplica ). +/// Teclado, ratón y audio: solo los de la anfitriona. +/// +/// El mosaico se deshace al sacar a la anfitriona, al pararse, o cuando lleva +/// sin ningún cliente conectado. +/// +public sealed class MosaicService(StreamSessionService sessions, SeatManager seats) : BackgroundService +{ + private static readonly TimeSpan ClientGrace = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(120); + + private sealed class Group + { + public required Guid Host { get; init; } + /// Por orden de llegada; [0] es la anfitriona. + public readonly List Members = []; + /// Resolución RDP que tiene ahora cada miembro, para reconectar solo lo que cambia. + public readonly Dictionary Applied = []; + /// Con dos sesiones, una encima de otra en vez de una al lado de otra. Se conserva si entran y salen más. + public bool Stacked; + public DateTime? NoClientSince; + } + + private readonly object _sync = new(); + private readonly Dictionary _groups = []; + /// Un cambio a la vez: cada uno reconecta sesiones RDP (~1-2 s cada una). + private readonly SemaphoreSlim _ops = new(1, 1); + private int _generation; + + /// Sube con cada cambio de miembros o de sesión de Windows de un miembro. + public int Generation => Volatile.Read(ref _generation); + + /// Miembros del mosaico de por orden de llegada; vacío si no tiene. + public IReadOnlyList Members(Guid host) + { + lock (_sync) return _groups.TryGetValue(host, out var g) ? g.Members.ToList() : []; + } + + /// Con dos sesiones, el mosaico de las pone una encima de otra. + public bool IsStacked(Guid host) + { + lock (_sync) return _groups.TryGetValue(host, out var g) && g.Stacked; + } + + /// + /// Con dos sesiones, una al lado de otra ( = false) o una encima de + /// otra (true). Con tres o cuatro no cambia nada ahora, pero se recuerda para cuando vuelvan a ser dos. + /// + /// La sesión no es anfitriona de un mosaico. + public async Task SetStackedAsync(Guid hostId, bool stacked, CancellationToken ct = default) + { + await _ops.WaitAsync(ct); + try + { + Group g; + lock (_sync) + { + if (!_groups.TryGetValue(hostId, out g!)) throw new KeyNotFoundException(); + if (g.Stacked == stacked) return; + g.Stacked = stacked; + Renumber(g); + } + Logger.Log($"[Mosaico] Dos sesiones {(stacked ? "una encima de otra" : "una al lado de otra")}.", hostId); + try { Apply(g); } + catch (Exception ex) + { + Logger.Error($"[Mosaico] No se pudo cambiar la disposición: {ex.Message}", hostId); + throw new InvalidOperationException(RequestCulture.T("error.mosaicApply", ex.Message)); + } + } + finally + { + _ops.Release(); + } + } + + /// + /// Sesión a la que va el mando número creado por el Sunshine de + /// : la sesión en esa posición del mosaico, o la anfitriona si no hay + /// tantas. Null si no es anfitriona de un mosaico. + /// + public Guid? PadTarget(Guid creator, int index) + { + lock (_sync) + { + if (!_groups.TryGetValue(creator, out var g)) return null; + return index >= 0 && index < g.Members.Count ? g.Members[index] : creator; + } + } + + /// Une al mosaico de (lo crea si no existe). + /// Alguna de las dos sesiones no existe. + /// No se puede unir; el mensaje dice por qué. + public async Task JoinAsync(Guid hostId, Guid guestId, CancellationToken ct = default) + { + await _ops.WaitAsync(ct); + try + { + var host = sessions.Get(hostId) ?? throw new KeyNotFoundException(); + var guest = sessions.Get(guestId) ?? throw new KeyNotFoundException(); + Validate(host, guest); + + if (guest.State is not (SessionState.Running or SessionState.Starting)) + { + Logger.Log($"[Mosaico] Arrancando '{guest.Name}' para unirla al mosaico de '{host.Name}'…", guestId); + sessions.Start(guestId); + } + await WaitRunningAsync(guest, ct); + Validate(host, guest); // pudo cambiar algo mientras arrancaba + + Group g; + lock (_sync) + { + if (!_groups.TryGetValue(hostId, out g!)) + { + g = new Group { Host = hostId }; + g.Members.Add(hostId); + g.Applied[hostId] = (host.RdpWidth, host.RdpHeight); + _groups[hostId] = g; + } + g.Members.Add(guestId); + g.Applied[guestId] = (guest.RdpWidth, guest.RdpHeight); + Renumber(g); + g.NoClientSince = null; + _generation++; + } + sessions.PauseSunshine(guestId, $"mosaico de '{host.Name}'"); + Logger.Log($"[Mosaico] '{guest.Name}' se une al mosaico de '{host.Name}' ({g.Members.Count}/{MosaicLayout.MaxMembers}).", hostId); + + try + { + Apply(g); + } + catch (Exception ex) + { + Logger.Error($"[Mosaico] No se pudo colocar '{guest.Name}' en el mosaico de '{host.Name}': {ex.Message}", hostId); + Remove(g, guestId); + throw new InvalidOperationException(RequestCulture.T("error.mosaicApply", ex.Message)); + } + } + finally + { + _ops.Release(); + } + } + + /// + /// Saca del mosaico de . Sacar a la + /// anfitriona deshace el mosaico. + /// + /// No hay ese mosaico o la sesión no está en él. + public async Task LeaveAsync(Guid hostId, Guid memberId, CancellationToken ct = default) + { + await _ops.WaitAsync(ct); + try + { + Group g; + lock (_sync) + { + if (!_groups.TryGetValue(hostId, out g!) || !g.Members.Contains(memberId)) + throw new KeyNotFoundException(); + } + if (memberId == hostId) Dissolve(g, "la anfitriona sale"); + else Remove(g, memberId); + } + finally + { + _ops.Release(); + } + } + + private void Validate(StreamSession host, StreamSession guest) + { + if (host.Id == guest.Id) + throw new InvalidOperationException(RequestCulture.T("error.mosaicSelf")); + if (host.State != SessionState.Running || host.RdpSessionId == 0 || host.Local) + throw new InvalidOperationException(RequestCulture.T("error.mosaicHostStopped")); + if (guest.Local) + throw new InvalidOperationException(RequestCulture.T("error.mosaicCapture", guest.Name)); + foreach (var s in new[] { host, guest }) + { + if (!StreamSessionService.CapturesWithRemoteIdd(s)) + throw new InvalidOperationException(RequestCulture.T("error.mosaicCapture", s.Name)); + if (seats.GetAll().Any(seat => seat.SessionId == s.Id && seat.State is SeatState.Running or SeatState.Starting)) + throw new InvalidOperationException(RequestCulture.T("error.mosaicSeat", s.Name)); + } + if (host.ClientsMoonlight + host.ClientsWebRtc is 0) + throw new InvalidOperationException(RequestCulture.T("error.mosaicNoClient", host.Name)); + if (guest.ClientsMoonlight + guest.ClientsWebRtc is > 0) + throw new InvalidOperationException(RequestCulture.T("error.mosaicGuestStreaming", guest.Name)); + + lock (_sync) + { + if (host.MosaicHost is { } h && h != host.Id) + throw new InvalidOperationException(RequestCulture.T("error.mosaicBusy", host.Name)); + if (guest.MosaicHost is not null || _groups.ContainsKey(guest.Id)) + throw new InvalidOperationException(RequestCulture.T("error.mosaicBusy", guest.Name)); + if (_groups.TryGetValue(host.Id, out var g) && g.Members.Count >= MosaicLayout.MaxMembers) + throw new InvalidOperationException(RequestCulture.T("error.mosaicFull")); + } + } + + private static async Task WaitRunningAsync(StreamSession session, CancellationToken ct) + { + var deadline = DateTime.UtcNow + StartTimeout; + while (session.State != SessionState.Running || session.RdpSessionId == 0) + { + if (session.State is SessionState.Error or SessionState.Stopped || DateTime.UtcNow > deadline) + throw new InvalidOperationException(RequestCulture.T("error.mosaicGuestStart", session.Name)); + await Task.Delay(500, ct); + } + } + + /// + /// Deja cada miembro en su parte: escribe el fichero de Sunshine, reconecta las sesiones cuyo + /// tamaño cambia y vuelve a escribirlo (una reconexión puede cambiar la sesión de Windows). + /// + private void Apply(Group g) + { + var host = sessions.Get(g.Host) ?? throw new KeyNotFoundException(); + int w = host.RdpWidth, h = host.RdpHeight, hz = host.RdpFrameRate; + var members = g.Members.Select(id => sessions.Get(id) ?? throw new KeyNotFoundException()).ToList(); + var areas = MosaicLayout.Areas(members.Count, g.Stacked); + + WriteFile(g, members, w, h); + for (int i = 0; i < members.Count; i++) + { + var target = MosaicLayout.PartSize(areas[i], w, h); + if (g.Applied.GetValueOrDefault(members[i].Id) == target) continue; + sessions.ReconnectRdp(members[i].Id, target.Width, target.Height, hz); + g.Applied[members[i].Id] = target; + Logger.Log($"[Mosaico] '{members[i].Name}' en la parte {areas[i]} ({target.Width}x{target.Height}).", g.Host); + } + WriteFile(g, members, w, h); + Interlocked.Increment(ref _generation); + } + + /// Saca un invitado: vuelve a su resolución, se reanuda su Sunshine y se recoloca el resto. + private void Remove(Group g, Guid id, bool reapply = true) + { + lock (_sync) + { + g.Members.Remove(id); + Renumber(g); + _generation++; + } + var s = sessions.Get(id); + if (s is not null) + { + Clear(s); + RestoreOwnSize(g, s); + Logger.Log($"[Mosaico] '{s.Name}' sale del mosaico.", g.Host); + } + g.Applied.Remove(id); + sessions.ResumeSunshine(id); + + if (!reapply) return; + if (g.Members.Count <= 1) + { + End(g); + return; + } + try { Apply(g); } + catch (Exception ex) { Logger.Error($"[Mosaico] No se pudo recolocar el mosaico: {ex.Message}", g.Host); } + } + + private void Dissolve(Group g, string reason) + { + Logger.Log($"[Mosaico] Se deshace el mosaico: {reason}.", g.Host); + foreach (var id in g.Members.Skip(1).ToList()) Remove(g, id, reapply: false); + End(g); + } + + /// Solo queda la anfitriona: sin fichero, a su resolución y fuera de la lista. + private void End(Group g) + { + DeleteFile(g.Host); + if (sessions.Get(g.Host) is { } host) + { + Clear(host); + RestoreOwnSize(g, host); + } + lock (_sync) + { + _groups.Remove(g.Host); + _generation++; + } + } + + /// Anfitriona, posición y recuento de cada miembro, para el panel. Con tomado. + private void Renumber(Group g) + { + for (int i = 0; i < g.Members.Count; i++) + { + if (sessions.Get(g.Members[i]) is not { } s) continue; + s.MosaicHost = g.Host; + s.MosaicIndex = i; + s.MosaicCount = i == 0 ? g.Members.Count : null; + s.MosaicStacked = i == 0 && g.Stacked; + } + } + + private static void Clear(StreamSession s) + { + s.MosaicHost = null; + s.MosaicIndex = null; + s.MosaicCount = null; + s.MosaicStacked = false; + } + + private void RestoreOwnSize(Group g, StreamSession s) + { + if (s.State != SessionState.Running || s.RdpSessionId == 0) return; + if (g.Applied.GetValueOrDefault(s.Id) == (s.RdpWidth, s.RdpHeight)) return; + try + { + sessions.ReconnectRdp(s.Id, s.RdpWidth, s.RdpHeight, s.RdpFrameRate); + } + catch (Exception ex) + { + Logger.Warning($"[Mosaico] '{s.Name}' no pudo volver a su resolución: {ex.Message}", g.Host); + } + } + + private static void WriteFile(Group g, List members, int w, int h) + { + var path = Path.Combine(StreamSessionService.SunshineConfigDir(g.Host), MosaicLayout.FileName); + var text = MosaicLayout.Render(w, h, members.Select(m => m.RdpSessionId).ToList(), g.Stacked); + if (File.Exists(path) && File.ReadAllText(path) == text) return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, text); + File.Move(tmp, path, overwrite: true); + } + + private static void DeleteFile(Guid host) + { + try { File.Delete(Path.Combine(StreamSessionService.SunshineConfigDir(host), MosaicLayout.FileName)); } + catch (Exception ex) { Logger.Warning($"[Mosaico] No se pudo borrar {MosaicLayout.FileName}: {ex.Message}", host); } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Un fichero de una ejecución anterior dejaría a Sunshine en mosaico sin nadie que lo lleve. + foreach (var s in sessions.GetAll()) DeleteFile(s.Id); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await _ops.WaitAsync(stoppingToken); + try { Watch(); } + catch (Exception ex) { Logger.Warning($"[Mosaico] Error vigilando los mosaicos: {ex.Message}"); } + finally { _ops.Release(); } + } + } + + /// Saca a las sesiones paradas y deshace los mosaicos sin anfitriona o sin cliente. + private void Watch() + { + List groups; + lock (_sync) groups = _groups.Values.ToList(); + foreach (var g in groups) + { + var host = sessions.Get(g.Host); + if (host is null || host.State != SessionState.Running) + { + Dissolve(g, "la anfitriona se ha parado"); + continue; + } + foreach (var id in g.Members.Skip(1).ToList()) + if (sessions.Get(id) is not { State: SessionState.Running }) + Remove(g, id); + lock (_sync) + if (!_groups.ContainsKey(g.Host)) continue; + + // Sin dato (Sunshine reiniciándose o sin el endpoint) no se cuenta como desconectado. + if (host.ClientsMoonlight + host.ClientsWebRtc is 0) + { + g.NoClientSince ??= DateTime.UtcNow; + if (DateTime.UtcNow - g.NoClientSince > ClientGrace) + Dissolve(g, "el cliente se ha desconectado"); + } + else + { + g.NoClientSince = null; + } + } + } +} diff --git a/Services/Seats/SeatManager.cs b/Services/Seats/SeatManager.cs index b1dc99c..949662c 100644 --- a/Services/Seats/SeatManager.cs +++ b/Services/Seats/SeatManager.cs @@ -255,6 +255,8 @@ public void Start(Guid id) if (seat.State == SeatState.Running) return; if (seat.SessionId is not { } sessionId) throw new InvalidOperationException(BackendText.Get("error.assigned")); var session = sessions.Get(sessionId) ?? throw new InvalidOperationException(BackendText.Get("error.assigned")); + // Una sesión en un mosaico ya se ve en el stream de la anfitriona, a otro tamaño. + if (session.MosaicHost is not null) throw new InvalidOperationException(BackendText.Get("error.mosaicBusy", session.Name)); seat.State = SeatState.Starting; seat.ErrorMessage = null; diff --git a/Sunshine/sunshine.exe b/Sunshine/sunshine.exe index 1a40a40..2d89155 100644 Binary files a/Sunshine/sunshine.exe and b/Sunshine/sunshine.exe differ diff --git a/docs/mosaico.md b/docs/mosaico.md new file mode 100644 index 0000000..9dea14e --- /dev/null +++ b/docs/mosaico.md @@ -0,0 +1,83 @@ +# Mosaico (hasta cuatro sesiones en un stream) + +Un **mosaico** es una sesión que se está retransmitiendo por Moonlight o WebRTC (la +**anfitriona**) a la que se unen en caliente hasta otras tres. El cliente ve las sesiones en una +cuadrícula de 2×2, como la pantalla dividida de los puestos locales, pero en el stream y no en un +monitor del PC. + +## Cómo se usa + +1. Conecta un cliente a la sesión anfitriona (Moonlight o una TV por WebRTC). +2. En el panel, en el menú **⋯** de otra sesión, elige **Unir al stream de…** y la anfitriona. + La sesión se arranca si estaba parada. +3. La disposición se adapta a cuántas haya: 1 a pantalla entera, 2 en mitades (izquierda/derecha), + 3 o 4 en cuartos (con 3, el cuarto de abajo a la derecha queda en negro). Con dos, el menú de la + anfitriona permite **Poner una encima de otra** (arriba/abajo) y volver con **Poner una al lado + de otra**; la elección se recuerda si entran más sesiones y vuelven a quedar dos. +4. Para sacar una sesión: **Salir del mosaico** en su menú. En la anfitriona, **Deshacer el + mosaico** saca a todas. + +La tarjeta de cada sesión del mosaico lleva una etiqueta con un mapa 2×2 de su parte; al pasar el +ratón por encima se ve qué mando va a cada sesión. + +## Decisiones de diseño + +| Tema | Decisión | Motivo | +|---|---|---| +| Composición | En el Sunshine de la anfitriona (`capture = remoteidd`): abre los fotogramas RemoteIdd de cada sesión y los copia en un lienzo (`CopySubresourceRegion`, sin shaders) | Sin ventana presentadora ni recursión: la anfitriona es un cuartil más. Solo se copia la parte que cambió | +| Control | `osms-mosaic.txt` junto al `sunshine.conf` de la anfitriona; Sunshine lo relee cada 250 ms | Unir o sacar una sesión no corta el stream: Sunshine reinicia la captura | +| Lienzo | La resolución RDP de la anfitriona (`canvas W H`); el codificador lo escala al cliente como cualquier pantalla | `/api/streams` no da la resolución del cliente | +| Resolución de cada sesión | Se reconecta la RDP al tamaño de su parte (RemoteIdd acepta cualquier modo con `OsmsSeatWidth/Height`) | Sin escalado: cada sesión dibuja a su tamaño real | +| Sunshine de las invitadas | En pausa (`PauseSunshine`) | Nadie más lee su slot RemoteIdd; no se sirven a la vez por separado | +| Mandos | El mando N que crea el Sunshine de la anfitriona va a la sesión N (orden de llegada; 0 = anfitriona). Si no hay sesión N, se queda en la anfitriona. Si el mosaico cambia, se reasignan | Moonlight numera los mandos por orden de conexión | +| Teclado, ratón y audio | Solo los de la anfitriona; el ratón absoluto cae en su parte (fuera de ella se queda en el borde) | Primera versión | +| Fin del mosaico | Al sacar a la anfitriona, al pararse, o tras 60 s sin ningún cliente | Un cliente que se reconecta enseguida no lo pierde | + +## Fichero de control + +`sessions\\Sunshine\config\osms-mosaic.txt`, lo escribe el servicio: + +``` +# OpenStreamMS mosaic: +canvas 1920 1080 +7 TopLeft +9 TopRight +12 BottomLeft +``` + +Las partes son las de `SeatScreenArea`; Sunshine (`parse_mosaic_layout`, `mosaic_rect`) y el +servicio (`MosaicLayout`) reparten el lienzo igual que `SeatMonitor.Region`: con un tamaño impar, +la mitad izquierda o de arriba se queda el lado menor. Al arrancar, el servicio borra los ficheros +que hubieran quedado de una ejecución anterior. + +## API + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/sessions/{id}/mosaic` | Miembros por orden de llegada con su parte | +| POST | `/api/sessions/{id}/mosaic/{guestId}` | Une `guestId` al stream de `{id}` | +| DELETE | `/api/sessions/{id}/mosaic/{memberId}` | Saca a `memberId`; si es la anfitriona, deshace el mosaico | +| PUT | `/api/sessions/{id}/mosaic/layout` | Con dos sesiones, `{ "stacked": true }` una encima de otra; `false`, una al lado de otra | + +`GET /api/sessions` incluye en cada sesión `mosaicHost`, `mosaicIndex` y, en la anfitriona, +`mosaicCount` y `mosaicStacked`. + +## Requisitos y límites + +- Todas las sesiones con captura **RemoteIdd** y en la misma GPU. Sin HDR. +- La anfitriona necesita un cliente conectado para empezar; una invitada no puede tener uno propio. +- Una sesión no puede estar a la vez en un mosaico y en un puesto local encendido. +- Al unir o sacar una sesión, las que cambian de tamaño se reconectan (~1-2 s cada una). +- Si el servicio se reinicia con un mosaico activo, el mosaico se pierde y las sesiones se quedan + con el tamaño de su parte hasta su siguiente reconexión o reinicio. + +## Archivos + +| Archivo | Qué hace | +|---|---| +| `Services/Mosaic/MosaicService.cs` | Unir, sacar, recolocar y vigilar los mosaicos | +| `Services/Mosaic/MosaicLayout.cs` | Partes por número de sesiones, tamaños y contenido de `osms-mosaic.txt` | +| `Core/Api/MosaicEndpoints.cs` | API REST | +| `Services/Gamepad/GamepadSessionIsolation.cs` | Mando N de la anfitriona → sesión N | +| `Native/RemoteIdd/FrameConsumer.*` | `ReadyEvent()` y `LatestFrameId()` para esperar a varias sesiones | +| Sunshine-Web-RTC `src/platform/windows/display_remoteidd.cpp` | Lectura del fichero y lienzo del mosaico | diff --git a/tests/OpenStreamMS.Tests/MosaicLayoutTests.cs b/tests/OpenStreamMS.Tests/MosaicLayoutTests.cs new file mode 100644 index 0000000..313e608 --- /dev/null +++ b/tests/OpenStreamMS.Tests/MosaicLayoutTests.cs @@ -0,0 +1,85 @@ +using OpenStreamMS.Services.Mosaic; +using OpenStreamMS.Services.Seats; + +namespace OpenStreamMS.Tests; + +public class MosaicLayoutTests +{ + [Fact] + public void Areas_AdaptToTheNumberOfSessions() + { + Assert.Equal([SeatScreenArea.Full], MosaicLayout.Areas(1)); + Assert.Equal([SeatScreenArea.Left, SeatScreenArea.Right], MosaicLayout.Areas(2)); + Assert.Equal([SeatScreenArea.TopLeft, SeatScreenArea.TopRight, SeatScreenArea.BottomLeft], MosaicLayout.Areas(3)); + Assert.Equal([SeatScreenArea.TopLeft, SeatScreenArea.TopRight, SeatScreenArea.BottomLeft, SeatScreenArea.BottomRight], + MosaicLayout.Areas(4)); + } + + [Fact] + public void Areas_Stacked_OnlyChangesTwoSessions() + { + Assert.Equal([SeatScreenArea.Top, SeatScreenArea.Bottom], MosaicLayout.Areas(2, stacked: true)); + Assert.Equal([SeatScreenArea.Full], MosaicLayout.Areas(1, stacked: true)); + Assert.Equal(MosaicLayout.Areas(3), MosaicLayout.Areas(3, stacked: true)); + Assert.Equal(MosaicLayout.Areas(4), MosaicLayout.Areas(4, stacked: true)); + } + + [Fact] + public void Render_Stacked_PutsSessionsTopAndBottom() + { + var lines = MosaicLayout.Render(1920, 1080, [7u, 9u], stacked: true).Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(["canvas 1920 1080", "7 Top", "9 Bottom"], lines[1..]); + Assert.Equal((1920, 540), MosaicLayout.PartSize(SeatScreenArea.Top, 1920, 1080)); + } + + [Theory] + [InlineData(1, 0b1111)] + [InlineData(2, 0b1111)] + [InlineData(3, 0b0111)] + [InlineData(4, 0b1111)] + public void Areas_NeverOverlap(int count, int expectedCells) + { + int used = 0; + foreach (var a in MosaicLayout.Areas(count)) + { + int cells = SeatAreas.Cells(a); + Assert.Equal(0, used & cells); + used |= cells; + } + Assert.Equal(expectedCells, used); + } + + [Theory] + [InlineData(SeatScreenArea.Full, 1920, 1080)] + [InlineData(SeatScreenArea.Left, 960, 1080)] + [InlineData(SeatScreenArea.Right, 960, 1080)] + [InlineData(SeatScreenArea.TopLeft, 960, 540)] + [InlineData(SeatScreenArea.BottomRight, 960, 540)] + public void PartSize_SplitsTheCanvas(SeatScreenArea area, int w, int h) + { + Assert.Equal((w, h), MosaicLayout.PartSize(area, 1920, 1080)); + } + + [Fact] + public void PartSize_GivesTheOddPixelToTheRightAndBottom() + { + // Mismo reparto que Sunshine (mosaic_rect) y que la pantalla dividida de los puestos. + Assert.Equal((960, 540), MosaicLayout.PartSize(SeatScreenArea.TopLeft, 1921, 1081)); + Assert.Equal((961, 541), MosaicLayout.PartSize(SeatScreenArea.BottomRight, 1921, 1081)); + } + + [Fact] + public void Render_ListsCanvasAndSessionsInArrivalOrder() + { + var text = MosaicLayout.Render(2560, 1440, [7u, 9u, 12u]); + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.StartsWith("#", lines[0]); + Assert.Equal(["canvas 2560 1440", "7 TopLeft", "9 TopRight", "12 BottomLeft"], lines[1..]); + } + + [Fact] + public void Render_UsesLfOnly() + { + Assert.DoesNotContain('\r', MosaicLayout.Render(1920, 1080, [1u, 2u])); + } +} diff --git a/tests/i18n-backend.test.cjs b/tests/i18n-backend.test.cjs index 35d1293..68e1dc8 100644 --- a/tests/i18n-backend.test.cjs +++ b/tests/i18n-backend.test.cjs @@ -195,11 +195,11 @@ const backendTextCode = fs.readFileSync(path.join(root, 'Services/Localization/B // We need to test the actual compiled code. Let's create a test that runs the actual .NET code. // For now, test the catalog integrity. -test('All 22 backend catalogs have exactly 86 keys', () => { +test('All 22 backend catalogs have exactly 94 keys', () => { const codes = Object.keys(backendCatalogs).sort(); assert.equal(codes.length, 22); for (const [lang, table] of Object.entries(backendCatalogs)) { - assert.equal(Object.keys(table).length, 86, lang); + assert.equal(Object.keys(table).length, 94, lang); for (const [key, value] of Object.entries(table)) { assert.equal(typeof value, 'string', lang + ':' + key); assert.ok(value.length > 0, lang + ':' + key + ' is empty');