Merge pull request #125 from Pandipipas/remove-iframe-preview-from-graphicsview

Refina GraphicsView: quita previews iframe y agrega selector de skin para scoreboard
This commit is contained in:
Pandipipas
2026-02-20 23:15:15 +01:00
committed by GitHub
8 changed files with 190 additions and 92 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"scoreboardSkin": {
"type": "string",
"default": "scoreboard/main.html"
}
},
"required": ["scoreboardSkin"],
"default": {
"scoreboardSkin": "scoreboard/main.html"
}
}
+1
View File
@@ -12,5 +12,6 @@ const thisBundle = 'scoreko-dev';
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle); export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle); export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle); export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle);
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>('graphicsSettings', thisBundle);
export const commentaryReplicant = useReplicant<Schemas.Commentary>('commentary', thisBundle); export const commentaryReplicant = useReplicant<Schemas.Commentary>('commentary', thisBundle);
+9 -6
View File
@@ -46,8 +46,9 @@ type Translations = {
graphicsNoConfigured: string; graphicsNoConfigured: string;
graphicsCopyUrl: string; graphicsCopyUrl: string;
graphicsDragObs: string; graphicsDragObs: string;
graphicsOverlayPreview: string; graphicsScoreboard: string;
graphicsPreviewTitle: string; graphicsCommentary: string;
graphicsSkinLabel: string;
commentaryTitle: string; commentaryTitle: string;
commentaryCommentator1: string; commentaryCommentator1: string;
commentaryCommentator2: string; commentaryCommentator2: string;
@@ -120,8 +121,9 @@ const messages: Record<Locale, Translations> = {
graphicsNoConfigured: 'There are no graphics configured in this bundle.', graphicsNoConfigured: 'There are no graphics configured in this bundle.',
graphicsCopyUrl: 'Copy URL', graphicsCopyUrl: 'Copy URL',
graphicsDragObs: 'Drag into OBS', graphicsDragObs: 'Drag into OBS',
graphicsOverlayPreview: 'Overlay preview (real)', graphicsScoreboard: 'Scoreboard',
graphicsPreviewTitle: 'Graphic preview', graphicsCommentary: 'Commentary',
graphicsSkinLabel: 'Skin',
commentaryTitle: 'Commentary', commentaryTitle: 'Commentary',
commentaryCommentator1: 'Commentator #1', commentaryCommentator1: 'Commentator #1',
commentaryCommentator2: 'Commentator #2', commentaryCommentator2: 'Commentator #2',
@@ -190,8 +192,9 @@ const messages: Record<Locale, Translations> = {
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.', graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
graphicsCopyUrl: 'Copiar URL', graphicsCopyUrl: 'Copiar URL',
graphicsDragObs: 'Arrastrar a OBS', graphicsDragObs: 'Arrastrar a OBS',
graphicsOverlayPreview: 'Vista previa del overlay (real)', graphicsScoreboard: 'Scoreboard',
graphicsPreviewTitle: 'Vista previa del gráfico', graphicsCommentary: 'Comentario',
graphicsSkinLabel: 'Skin',
commentaryTitle: 'Comentario', commentaryTitle: 'Comentario',
commentaryCommentator1: 'Comentarista #1', commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2', commentaryCommentator2: 'Comentarista #2',
+123 -84
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' }); defineOptions({ name: 'GraphicsView' });
@@ -8,26 +9,30 @@ defineOptions({ name: 'GraphicsView' });
import bundlePackage from '../../../../package.json'; import bundlePackage from '../../../../package.json';
type GraphicConfig = { type GraphicConfig = {
name?: string;
title?: string;
file: string; file: string;
width?: number; width?: number;
height?: number; height?: number;
}; };
type PreviewKind = 'scoreboard' | 'commentary' | null; type GraphicCard = {
id: 'scoreboard' | 'commentary';
label: string;
graphic: GraphicConfig;
skinOptions?: Array<{ label: string; value: string }>;
};
useHead(() => ({ title: t('graphicsTitle') })); useHead(() => ({ title: t('graphicsTitle') }));
const graphics = computed<GraphicConfig[]>( const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
() => bundlePackage.nodecg?.graphics ?? [],
);
const baseUrl = computed(() => { const baseUrl = computed(() => {
const bundleName = bundlePackage.name ?? 'bundle'; const bundleName = bundlePackage.name ?? 'bundle';
return `${window.location.origin}/bundles/${bundleName}/graphics/`; return `${window.location.origin}/bundles/${bundleName}/graphics/`;
}); });
const buildGraphicUrl = (graphic: GraphicConfig) => const buildGraphicUrl = (graphic: GraphicConfig) => `${baseUrl.value}${graphic.file}`;
`${baseUrl.value}${graphic.file}`;
const buildGraphicName = (graphic: GraphicConfig) => { const buildGraphicName = (graphic: GraphicConfig) => {
const cleaned = graphic.file.replace(/\/?main\.html$/i, ''); const cleaned = graphic.file.replace(/\/?main\.html$/i, '');
@@ -35,6 +40,96 @@ const buildGraphicName = (graphic: GraphicConfig) => {
return parts.at(-1) ?? graphic.file; return parts.at(-1) ?? graphic.file;
}; };
const getGraphicKey = (graphic: GraphicConfig) =>
(graphic.name ?? buildGraphicName(graphic)).toLowerCase();
const scoreboardGraphics = computed(() =>
graphics.value.filter((graphic) => getGraphicKey(graphic).includes('scoreboard')),
);
const canonicalScoreboardGraphic = computed(() => {
const explicitDefault = scoreboardGraphics.value.find(
(graphic) => getGraphicKey(graphic) === 'scoreboard',
);
return explicitDefault ?? scoreboardGraphics.value[0];
});
const commentaryGraphic = computed(() =>
graphics.value.find((graphic) => getGraphicKey(graphic).includes('commentary')),
);
const selectedScoreboardSkin = ref<string>('');
watch(
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
([availableSkins, replicatedSkin]) => {
if (availableSkins.length === 0) {
selectedScoreboardSkin.value = '';
return;
}
const hasReplicatedSkin = availableSkins.some((graphic) => graphic.file === replicatedSkin);
if (hasReplicatedSkin) {
selectedScoreboardSkin.value = replicatedSkin ?? "";
return;
}
const hasCurrentSkin = availableSkins.some(
(graphic) => graphic.file === selectedScoreboardSkin.value,
);
if (!hasCurrentSkin) {
selectedScoreboardSkin.value = availableSkins[0]!.file;
}
},
{ immediate: true },
);
watch(
selectedScoreboardSkin,
(value) => {
if (!value || !graphicsSettingsReplicant) {
return;
}
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
return;
}
graphicsSettingsReplicant.data = {
scoreboardSkin: value,
};
graphicsSettingsReplicant.save();
},
{ immediate: true },
);
const cards = computed<GraphicCard[]>(() => {
const result: GraphicCard[] = [];
if (canonicalScoreboardGraphic.value) {
result.push({
id: 'scoreboard',
label: t('graphicsScoreboard'),
graphic: canonicalScoreboardGraphic.value,
skinOptions: scoreboardGraphics.value.map((graphic) => ({
label: graphic.title ?? buildGraphicName(graphic),
value: graphic.file,
})),
});
}
if (commentaryGraphic.value) {
result.push({
id: 'commentary',
label: t('graphicsCommentary'),
graphic: commentaryGraphic.value,
});
}
return result;
});
const copyUrl = async (graphic: GraphicConfig) => { const copyUrl = async (graphic: GraphicConfig) => {
const url = buildGraphicUrl(graphic); const url = buildGraphicUrl(graphic);
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
@@ -50,20 +145,6 @@ const copyUrl = async (graphic: GraphicConfig) => {
document.body.removeChild(input); document.body.removeChild(input);
}; };
const getPreviewKind = (graphic: GraphicConfig): PreviewKind => {
const name = buildGraphicName(graphic).toLowerCase();
if (name.includes('scoreboard')) {
return 'scoreboard';
}
if (name.includes('commentary')) {
return 'commentary';
}
return null;
};
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => { const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
const url = buildGraphicUrl(graphic); const url = buildGraphicUrl(graphic);
const name = buildGraphicName(graphic); const name = buildGraphicName(graphic);
@@ -92,7 +173,7 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
</div> </div>
<div <div
v-if="graphics.length === 0" v-if="cards.length === 0"
class="text-body2 text-grey-5" class="text-body2 text-grey-5"
> >
{{ t('graphicsNoConfigured') }} {{ t('graphicsNoConfigured') }}
@@ -100,8 +181,8 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div <div
v-for="graphic in graphics" v-for="card in cards"
:key="graphic.file" :key="card.id"
class="col-12 col-md-6" class="col-12 col-md-6"
> >
<QCard <QCard
@@ -111,96 +192,54 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<QCardSection class="row items-center justify-between"> <QCardSection class="row items-center justify-between">
<div> <div>
<div class="text-h6"> <div class="text-h6">
{{ buildGraphicName(graphic) }} {{ card.label }}
</div> </div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">
{{ graphic.file }} {{ card.graphic.file }}
</div> </div>
</div> </div>
<QBadge <QBadge
color="primary" color="primary"
outline outline
> >
{{ graphic.width ?? 1920 }}x{{ graphic.height ?? 1080 }} {{ card.graphic.width ?? 1920 }}x{{ card.graphic.height ?? 1080 }}
</QBadge> </QBadge>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
<QSelect
v-if="card.skinOptions"
v-model="selectedScoreboardSkin"
class="q-mb-md"
dark
dense
emit-value
map-options
outlined
:label="t('graphicsSkinLabel')"
:options="card.skinOptions"
/>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<QBtn <QBtn
color="primary" color="primary"
icon="content_copy" icon="content_copy"
:label="t('graphicsCopyUrl')" :label="t('graphicsCopyUrl')"
@click="copyUrl(graphic)" @click="copyUrl(card.graphic)"
/> />
<QBtn <QBtn
color="secondary" color="secondary"
icon="open_with" icon="open_with"
:label="t('graphicsDragObs')" :label="t('graphicsDragObs')"
draggable="true" draggable="true"
@dragstart="onDragStart($event, graphic)" @dragstart="onDragStart($event, card.graphic)"
/> />
</div> </div>
<div
v-if="getPreviewKind(graphic)"
class="graphics-preview q-mt-md"
>
<div class="graphics-preview__label text-caption text-grey-5 q-mb-sm">
{{ t('graphicsOverlayPreview') }}
</div>
<div class="graphics-preview__frame-wrap">
<iframe
class="graphics-preview__frame"
:src="buildGraphicUrl(graphic)"
:title="t('graphicsPreviewTitle')"
loading="lazy"
/>
</div>
</div>
</QCardSection> </QCardSection>
</QCard> </QCard>
</div> </div>
</div> </div>
</QPage> </QPage>
</template> </template>
<style scoped>
.graphics-preview {
border-top: 1px solid rgb(255 255 255 / 10%);
padding-top: 14px;
}
.graphics-preview__frame-wrap {
width: 100%;
aspect-ratio: 16 / 9;
container-type: inline-size;
border-radius: 8px;
border: 1px solid rgb(255 255 255 / 12%);
background:
linear-gradient(45deg, rgb(255 255 255 / 8%) 25%, transparent 25%),
linear-gradient(-45deg, rgb(255 255 255 / 8%) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgb(255 255 255 / 8%) 75%),
linear-gradient(-45deg, transparent 75%, rgb(255 255 255 / 8%) 75%),
rgb(8 8 8 / 70%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
overflow: hidden;
position: relative;
}
.graphics-preview__frame {
--preview-zoom: 0.50;
position: absolute;
top: 50%;
left: 50%;
width: calc(100% / var(--preview-zoom));
height: calc(100% / var(--preview-zoom));
border: 0;
transform: translate(-50%, -50%) scale(var(--preview-zoom));
transform-origin: center;
}
</style>
+15 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters'; import { getCharactersByGame } from '../../shared/fighting-characters';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
@@ -15,6 +15,20 @@ const defaultScoreboard: Schemas.Scoreboard = {
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {}); const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard); const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard-2xko/main.html');
watch(
scoreboardSkin,
(skin) => {
if (skin !== 'scoreboard-2xko/main.html') {
const targetUrl = new URL('../scoreboard/main.html', window.location.href).toString();
if (window.location.href !== targetUrl) {
window.location.replace(targetUrl);
}
}
},
{ immediate: true },
);
const leftName = computed(() => scoreboard.value.leftNameOverride || players.value[scoreboard.value.leftPlayerId]?.gamertag || 'PLAYER 1'); const leftName = computed(() => scoreboard.value.leftNameOverride || players.value[scoreboard.value.leftPlayerId]?.gamertag || 'PLAYER 1');
const rightName = computed(() => scoreboard.value.rightNameOverride || players.value[scoreboard.value.rightPlayerId]?.gamertag || 'PLAYER 2'); const rightName = computed(() => scoreboard.value.rightNameOverride || players.value[scoreboard.value.rightPlayerId]?.gamertag || 'PLAYER 2');
+15 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/countries';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
@@ -26,6 +26,20 @@ const defaultScoreboard: Schemas.Scoreboard = {
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {}); const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard); const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard/main.html');
watch(
scoreboardSkin,
(skin) => {
if (skin !== 'scoreboard/main.html') {
const targetUrl = new URL('../scoreboard-2xko/main.html', window.location.href).toString();
if (window.location.href !== targetUrl) {
window.location.replace(targetUrl);
}
}
},
{ immediate: true },
);
const leftName = computed(() => { const leftName = computed(() => {
if (scoreboard.value.leftNameOverride) { if (scoreboard.value.leftNameOverride) {
+1
View File
@@ -7,5 +7,6 @@
export type { Commentary } from './schemas/commentary.d.ts'; export type { Commentary } from './schemas/commentary.d.ts';
export type { Configschema } from './schemas/configschema.d.ts'; export type { Configschema } from './schemas/configschema.d.ts';
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts'; export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
export type { GraphicsSettings } from './schemas/graphicsSettings.d.ts';
export type { Players } from './schemas/players.d.ts'; export type { Players } from './schemas/players.d.ts';
export type { Scoreboard } from './schemas/scoreboard.d.ts'; export type { Scoreboard } from './schemas/scoreboard.d.ts';
+11
View File
@@ -0,0 +1,11 @@
/* prettier-ignore */
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface GraphicsSettings {
scoreboardSkin: string;
}