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 `${cells}${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');