@mundogamernetwork/shared-ui 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/checkout/MgPaymentMethodSelector.vue +3 -1
- package/components/keys/KeyBrowser.vue +2 -2
- package/components/playtest/PlaytestResponseFlow.vue +7 -1
- package/components/playtest/access-grant/AccessGrantPanel.vue +80 -0
- package/components/playtest/access-grant/AccessGrantReveal.vue +132 -0
- package/components/playtest/access-grant/AccessGrantWaiver.vue +170 -0
- package/components/playtest/video/RecordedSessionPlayer.vue +162 -0
- package/components/playtest/video/VideoPlaytestCapture.vue +195 -0
- package/components/ui/MgPaymentMethods.vue +20 -0
- package/composables/usePaymentMethods.ts +43 -12
- package/composables/usePlaytestAccessGrant.ts +88 -0
- package/composables/usePlaytestVideoRecording.ts +191 -0
- package/locales/de.json +1 -0
- package/locales/en.json +1 -0
- package/locales/pt-BR.json +1 -0
- package/locales/ro.json +1 -0
- package/package.json +1 -1
- package/pages/key-campaigns/redeem-key.vue +36 -0
- package/services/campaignService.ts +14 -0
- package/services/playtestTesterService.ts +63 -0
|
@@ -48,6 +48,7 @@ function gatewayKey(m: { id: number; name: string }): string {
|
|
|
48
48
|
const n = (m.name ?? "").trim().toLowerCase();
|
|
49
49
|
if (n === "paypal" || m.id === 2) return "paypal";
|
|
50
50
|
if (n === "stripe" || m.id === 3) return "stripe";
|
|
51
|
+
if (n === "pix" || m.id === 4) return "pix";
|
|
51
52
|
return n;
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -60,11 +61,12 @@ function getMethodId(m: { id: number; name: string }): number {
|
|
|
60
61
|
const key = gatewayKey(m);
|
|
61
62
|
if (key === "paypal") return 2;
|
|
62
63
|
if (key === "stripe") return 3;
|
|
64
|
+
if (key === "pix") return 4;
|
|
63
65
|
return m.id;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function getLabel(m: { id: number; name: string }): string {
|
|
67
|
-
const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe" };
|
|
69
|
+
const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe", pix: "Pix" };
|
|
68
70
|
return labels[gatewayKey(m)] ?? m.name;
|
|
69
71
|
}
|
|
70
72
|
</script>
|
|
@@ -149,7 +149,7 @@ function handleRequestKey(pool: KeyPool) {
|
|
|
149
149
|
|
|
150
150
|
async function _checkNotifPrompt() {
|
|
151
151
|
try {
|
|
152
|
-
const res = await httpService.get('/user/notification-preferences', {
|
|
152
|
+
const res = await httpService.get('/public/user/notification-preferences', {
|
|
153
153
|
params: { category: 'key_campaigns' },
|
|
154
154
|
})
|
|
155
155
|
if (res.data?.data === null) {
|
|
@@ -163,7 +163,7 @@ async function _checkNotifPrompt() {
|
|
|
163
163
|
async function enableNotifAlerts() {
|
|
164
164
|
notifSaving.value = true
|
|
165
165
|
try {
|
|
166
|
-
await httpService.put('/user/notification-preferences', {
|
|
166
|
+
await httpService.put('/public/user/notification-preferences', {
|
|
167
167
|
category: 'key_campaigns',
|
|
168
168
|
enabled: true,
|
|
169
169
|
channel_email: true,
|
|
@@ -39,8 +39,9 @@ import CriterionRow from "./accessibility/CriterionRow.vue";
|
|
|
39
39
|
import ChecklistItemRow from "./compliance/ChecklistItemRow.vue";
|
|
40
40
|
import SessionCard from "./moderated/SessionCard.vue";
|
|
41
41
|
import JoinCallPanel from "./moderated/JoinCallPanel.vue";
|
|
42
|
+
import VideoPlaytestCapture from "./video/VideoPlaytestCapture.vue";
|
|
42
43
|
|
|
43
|
-
type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated";
|
|
44
|
+
type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated" | "video_playtest";
|
|
44
45
|
|
|
45
46
|
const props = defineProps<{
|
|
46
47
|
campaignId: number;
|
|
@@ -306,6 +307,11 @@ async function cancelSessionFlow() {
|
|
|
306
307
|
</button>
|
|
307
308
|
</div>
|
|
308
309
|
|
|
310
|
+
<!-- ── Video Playtest ── -->
|
|
311
|
+
<div v-else-if="category === 'video_playtest'">
|
|
312
|
+
<VideoPlaytestCapture :campaign-id="campaignId" @submitted="handleDone" />
|
|
313
|
+
</div>
|
|
314
|
+
|
|
309
315
|
<!-- ── Moderated ── -->
|
|
310
316
|
<div v-else-if="category === 'moderated'">
|
|
311
317
|
<JoinCallPanel
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Single orchestrator for an accepted response's game-access grant — the
|
|
3
|
+
// component both agency-frontend (mirrored /testing section) and
|
|
4
|
+
// community-frontend (My Tests) actually mount, one per accepted response.
|
|
5
|
+
// Mirrors PlaytestResponseFlow.vue's role for the submission side: owns the
|
|
6
|
+
// composable instance, dispatches to AccessGrantWaiver (not yet signed) or
|
|
7
|
+
// AccessGrantReveal (signed), and renders nothing if the response has no
|
|
8
|
+
// configured access grant.
|
|
9
|
+
import { onMounted } from "vue";
|
|
10
|
+
import { usePlaytestAccessGrant } from "../../../composables/usePlaytestAccessGrant";
|
|
11
|
+
import AccessGrantWaiver from "./AccessGrantWaiver.vue";
|
|
12
|
+
import AccessGrantReveal from "./AccessGrantReveal.vue";
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
responseId: number;
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const grantState = usePlaytestAccessGrant(props.responseId);
|
|
19
|
+
|
|
20
|
+
onMounted(() => {
|
|
21
|
+
grantState.loadAccessGrant();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function sendCode() {
|
|
25
|
+
grantState.initiateWaiver();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function verifyCode(otpCode: string) {
|
|
29
|
+
grantState.verifyWaiver(otpCode);
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div v-if="grantState.loading.value" class="access-grant-panel access-grant-panel--loading">
|
|
35
|
+
<div class="access-grant-panel__skeleton" />
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div v-else-if="grantState.grant.value" class="access-grant-panel">
|
|
39
|
+
<AccessGrantWaiver
|
|
40
|
+
v-if="!grantState.grant.value.revealed"
|
|
41
|
+
:submitting="grantState.submitting.value"
|
|
42
|
+
:waiver-initiated="grantState.waiverInitiated.value"
|
|
43
|
+
:error="grantState.genericError.value"
|
|
44
|
+
@send-code="sendCode"
|
|
45
|
+
@verify="verifyCode"
|
|
46
|
+
/>
|
|
47
|
+
<AccessGrantReveal
|
|
48
|
+
v-else
|
|
49
|
+
:access-type="grantState.grant.value.access_type"
|
|
50
|
+
:key-code="grantState.grant.value.key_code"
|
|
51
|
+
:external-link="grantState.grant.value.external_link"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped lang="scss">
|
|
57
|
+
.access-grant-panel {
|
|
58
|
+
padding: 16px;
|
|
59
|
+
background: var(--bg-app-badge);
|
|
60
|
+
border: 1px solid var(--button-secondary-default-bg);
|
|
61
|
+
|
|
62
|
+
&--loading {
|
|
63
|
+
padding: 0;
|
|
64
|
+
border: none;
|
|
65
|
+
background: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&__skeleton {
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 64px;
|
|
71
|
+
background: var(--bg-app-badge);
|
|
72
|
+
animation: access-grant-panel-pulse 1.4s ease-in-out infinite;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@keyframes access-grant-panel-pulse {
|
|
77
|
+
0%, 100% { opacity: 1; }
|
|
78
|
+
50% { opacity: 0.4; }
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Masked-reveal-then-copy for a signed access grant. Unlike the key-campaigns
|
|
3
|
+
// redeem flow, the real value already arrived in the API response the moment
|
|
4
|
+
// the waiver was verified — "reveal" here is a deliberate client-side
|
|
5
|
+
// unmask, not a second network round-trip. Mirrors
|
|
6
|
+
// pages/key-campaigns/redeem-key-approved.vue's code-box UI.
|
|
7
|
+
import { ref } from "vue";
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
accessType: "key" | "link";
|
|
11
|
+
keyCode?: string | null;
|
|
12
|
+
externalLink?: string | null;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const unmasked = ref(false);
|
|
16
|
+
const copied = ref(false);
|
|
17
|
+
|
|
18
|
+
const value = props.accessType === "key" ? (props.keyCode ?? "") : (props.externalLink ?? "");
|
|
19
|
+
|
|
20
|
+
async function copyValue() {
|
|
21
|
+
if (!value) return;
|
|
22
|
+
try {
|
|
23
|
+
await navigator.clipboard.writeText(value);
|
|
24
|
+
copied.value = true;
|
|
25
|
+
setTimeout(() => { copied.value = false; }, 2000);
|
|
26
|
+
} catch {
|
|
27
|
+
// clipboard API unavailable — the value is still selectable in the input
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="access-grant-reveal">
|
|
34
|
+
<h3 class="access-grant-reveal__title">
|
|
35
|
+
{{ accessType === "key"
|
|
36
|
+
? $t("playtest.access_grant.your_key", "Your game key")
|
|
37
|
+
: $t("playtest.access_grant.your_link", "Your download link") }}
|
|
38
|
+
</h3>
|
|
39
|
+
|
|
40
|
+
<div class="access-grant-reveal__box">
|
|
41
|
+
<input
|
|
42
|
+
v-if="!unmasked"
|
|
43
|
+
type="text"
|
|
44
|
+
value="••••••••••••••••"
|
|
45
|
+
readonly
|
|
46
|
+
class="access-grant-reveal__input access-grant-reveal__input--masked"
|
|
47
|
+
/>
|
|
48
|
+
<input
|
|
49
|
+
v-else
|
|
50
|
+
type="text"
|
|
51
|
+
:value="value"
|
|
52
|
+
readonly
|
|
53
|
+
class="access-grant-reveal__input"
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
<button
|
|
57
|
+
v-if="!unmasked"
|
|
58
|
+
type="button"
|
|
59
|
+
class="btn primary"
|
|
60
|
+
@click="unmasked = true"
|
|
61
|
+
>
|
|
62
|
+
{{ $t("playtest.access_grant.reveal", "Reveal") }}
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
v-else
|
|
66
|
+
type="button"
|
|
67
|
+
class="btn primary"
|
|
68
|
+
@click="copyValue"
|
|
69
|
+
>
|
|
70
|
+
<MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
|
|
71
|
+
{{ copied ? $t("playtest.access_grant.copied", "Copied") : $t("playtest.access_grant.copy", "Copy") }}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<a
|
|
76
|
+
v-if="unmasked && accessType === 'link'"
|
|
77
|
+
:href="value"
|
|
78
|
+
target="_blank"
|
|
79
|
+
rel="noopener"
|
|
80
|
+
class="access-grant-reveal__open-link"
|
|
81
|
+
>
|
|
82
|
+
{{ $t("playtest.access_grant.open_link", "Open link") }}
|
|
83
|
+
</a>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style scoped lang="scss">
|
|
88
|
+
.access-grant-reveal {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
gap: 10px;
|
|
92
|
+
|
|
93
|
+
&__title {
|
|
94
|
+
font-size: 15px;
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
color: var(--card-article-title, #fff);
|
|
97
|
+
margin: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
&__box {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&__input {
|
|
107
|
+
display: flex;
|
|
108
|
+
padding: 12px;
|
|
109
|
+
background-color: transparent;
|
|
110
|
+
height: 44px;
|
|
111
|
+
border: 1px solid var(--bg-app-badge);
|
|
112
|
+
width: 100%;
|
|
113
|
+
font-size: 14px;
|
|
114
|
+
color: var(--card-article-title, #fff);
|
|
115
|
+
|
|
116
|
+
&--masked {
|
|
117
|
+
letter-spacing: 3px;
|
|
118
|
+
color: rgba(128, 128, 128, 0.5);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&__open-link {
|
|
123
|
+
align-self: flex-start;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
color: var(--primary, #d297ff);
|
|
126
|
+
|
|
127
|
+
&:hover {
|
|
128
|
+
text-decoration: underline;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Liability-waiver signing step, gating an accepted response's game-access
|
|
3
|
+
// grant. Two-step UI: request the OTP code (sent to the tester's account
|
|
4
|
+
// email via the shared Documents module), then submit it. Purely
|
|
5
|
+
// presentational — AccessGrantPanel.vue owns the actual API calls.
|
|
6
|
+
import { ref } from "vue";
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
submitting: boolean;
|
|
10
|
+
waiverInitiated: boolean;
|
|
11
|
+
error?: string | null;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
(e: "send-code"): void;
|
|
16
|
+
(e: "verify", otpCode: string): void;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
const otpCode = ref("");
|
|
20
|
+
|
|
21
|
+
function submitCode() {
|
|
22
|
+
if (!otpCode.value) return;
|
|
23
|
+
emit("verify", otpCode.value);
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="access-grant-waiver">
|
|
29
|
+
<h3 class="access-grant-waiver__title">
|
|
30
|
+
{{ $t("playtest.access_grant.waiver_title", "Sign the liability waiver to unlock your game access") }}
|
|
31
|
+
</h3>
|
|
32
|
+
<p class="access-grant-waiver__body">
|
|
33
|
+
{{ $t(
|
|
34
|
+
"playtest.access_grant.waiver_body",
|
|
35
|
+
"The studio requires you to accept a liability waiver before revealing your key or download link. We'll send a verification code to your account email to confirm it's really you."
|
|
36
|
+
) }}
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<p v-if="error" class="access-grant-waiver__error">{{ error }}</p>
|
|
40
|
+
|
|
41
|
+
<div v-if="!waiverInitiated" class="access-grant-waiver__actions">
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
class="btn primary"
|
|
45
|
+
:disabled="submitting"
|
|
46
|
+
@click="emit('send-code')"
|
|
47
|
+
>
|
|
48
|
+
{{ submitting
|
|
49
|
+
? $t("playtest.access_grant.sending_code", "Sending code…")
|
|
50
|
+
: $t("playtest.access_grant.send_code", "Send verification code") }}
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div v-else class="access-grant-waiver__otp">
|
|
55
|
+
<label class="access-grant-waiver__label" for="access-grant-otp">
|
|
56
|
+
{{ $t("playtest.access_grant.enter_code", "Enter the 6-digit code we emailed you") }}
|
|
57
|
+
</label>
|
|
58
|
+
<div class="access-grant-waiver__otp-row">
|
|
59
|
+
<input
|
|
60
|
+
id="access-grant-otp"
|
|
61
|
+
v-model="otpCode"
|
|
62
|
+
type="text"
|
|
63
|
+
inputmode="numeric"
|
|
64
|
+
maxlength="6"
|
|
65
|
+
class="access-grant-waiver__otp-input"
|
|
66
|
+
:disabled="submitting"
|
|
67
|
+
@keyup.enter="submitCode"
|
|
68
|
+
/>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="btn primary"
|
|
72
|
+
:disabled="submitting || otpCode.length === 0"
|
|
73
|
+
@click="submitCode"
|
|
74
|
+
>
|
|
75
|
+
{{ submitting
|
|
76
|
+
? $t("playtest.access_grant.verifying", "Verifying…")
|
|
77
|
+
: $t("playtest.access_grant.verify", "Verify") }}
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="access-grant-waiver__resend"
|
|
83
|
+
:disabled="submitting"
|
|
84
|
+
@click="emit('send-code')"
|
|
85
|
+
>
|
|
86
|
+
{{ $t("playtest.access_grant.resend_code", "Resend code") }}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<style scoped lang="scss">
|
|
93
|
+
.access-grant-waiver {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
gap: 12px;
|
|
97
|
+
|
|
98
|
+
&__title {
|
|
99
|
+
font-size: 15px;
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
color: var(--card-article-title, #fff);
|
|
102
|
+
margin: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
&__body {
|
|
106
|
+
font-size: 13px;
|
|
107
|
+
color: var(--secondary-info-fg, #aaa);
|
|
108
|
+
margin: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
&__error {
|
|
112
|
+
font-size: 12px;
|
|
113
|
+
color: var(--danger, #e53935);
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&__actions {
|
|
118
|
+
display: flex;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
&__otp {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
gap: 8px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&__label {
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
color: var(--secondary-info-fg, #aaa);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
&__otp-row {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 8px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
&__otp-input {
|
|
139
|
+
width: 140px;
|
|
140
|
+
height: 44px;
|
|
141
|
+
padding: 0 12px;
|
|
142
|
+
background-color: transparent;
|
|
143
|
+
border: 1px solid var(--bg-app-badge);
|
|
144
|
+
color: var(--card-article-title, #fff);
|
|
145
|
+
font-size: 18px;
|
|
146
|
+
letter-spacing: 4px;
|
|
147
|
+
text-align: center;
|
|
148
|
+
|
|
149
|
+
&:focus {
|
|
150
|
+
border-color: var(--primary, #d297ff);
|
|
151
|
+
outline: 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
&__resend {
|
|
156
|
+
align-self: flex-start;
|
|
157
|
+
background: none;
|
|
158
|
+
border: none;
|
|
159
|
+
padding: 0;
|
|
160
|
+
font-size: 12px;
|
|
161
|
+
color: var(--primary, #d297ff);
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
|
|
164
|
+
&:disabled {
|
|
165
|
+
opacity: 0.45;
|
|
166
|
+
cursor: not-allowed;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Playback + clickable marker timeline for a Video Playtest recording.
|
|
3
|
+
// Native <video> — plays HLS natively in Safari; other browsers need an
|
|
4
|
+
// hls.js integration this first version doesn't include (documented
|
|
5
|
+
// limitation, not silently broken: shows a note when playback fails).
|
|
6
|
+
import { ref } from "vue";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
playbackUrl: string | null;
|
|
10
|
+
durationSeconds: number | null;
|
|
11
|
+
markers: { id: number; timestamp_seconds: number; comment: string; severity?: string | null }[];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const videoEl = ref<HTMLVideoElement | null>(null);
|
|
15
|
+
const playbackError = ref(false);
|
|
16
|
+
|
|
17
|
+
function seekTo(seconds: number) {
|
|
18
|
+
if (videoEl.value) {
|
|
19
|
+
videoEl.value.currentTime = seconds;
|
|
20
|
+
videoEl.value.play();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function markerPosition(seconds: number): string {
|
|
25
|
+
if (!props.durationSeconds) return "0%";
|
|
26
|
+
return `${Math.min(100, (seconds / props.durationSeconds) * 100)}%`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatSeconds(s: number): string {
|
|
30
|
+
const m = Math.floor(s / 60);
|
|
31
|
+
const sec = s % 60;
|
|
32
|
+
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function severityClass(severity?: string | null): string {
|
|
36
|
+
return severity ? `recorded-session-player__marker--${severity}` : "";
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="recorded-session-player">
|
|
42
|
+
<div v-if="!playbackUrl" class="recorded-session-player__pending">
|
|
43
|
+
{{ $t("playtest.video_player.processing", "Recording is still processing — check back shortly.") }}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<template v-else>
|
|
47
|
+
<video
|
|
48
|
+
ref="videoEl"
|
|
49
|
+
:src="playbackUrl"
|
|
50
|
+
controls
|
|
51
|
+
class="recorded-session-player__video"
|
|
52
|
+
@error="playbackError = true"
|
|
53
|
+
/>
|
|
54
|
+
<p v-if="playbackError" class="recorded-session-player__playback-error">
|
|
55
|
+
{{ $t("playtest.video_player.playback_error", "This browser can't play the recording directly — try Safari, or open the file link below.") }}
|
|
56
|
+
<a :href="playbackUrl" target="_blank" rel="noopener">{{ $t("playtest.video_player.open_direct", "Open recording") }}</a>
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
<div v-if="durationSeconds" class="recorded-session-player__timeline">
|
|
60
|
+
<button
|
|
61
|
+
v-for="marker in markers"
|
|
62
|
+
:key="marker.id"
|
|
63
|
+
type="button"
|
|
64
|
+
class="recorded-session-player__marker"
|
|
65
|
+
:class="severityClass(marker.severity)"
|
|
66
|
+
:style="{ left: markerPosition(marker.timestamp_seconds) }"
|
|
67
|
+
:title="`${formatSeconds(marker.timestamp_seconds)} — ${marker.comment}`"
|
|
68
|
+
@click="seekTo(marker.timestamp_seconds)"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<ul v-if="markers.length" class="recorded-session-player__marker-list">
|
|
74
|
+
<li v-for="marker in markers" :key="marker.id">
|
|
75
|
+
<button type="button" class="recorded-session-player__marker-jump" @click="seekTo(marker.timestamp_seconds)">
|
|
76
|
+
{{ formatSeconds(marker.timestamp_seconds) }}
|
|
77
|
+
</button>
|
|
78
|
+
<span :class="severityClass(marker.severity)">{{ marker.comment }}</span>
|
|
79
|
+
</li>
|
|
80
|
+
</ul>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<style scoped lang="scss">
|
|
85
|
+
.recorded-session-player {
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
gap: 10px;
|
|
89
|
+
|
|
90
|
+
&__pending {
|
|
91
|
+
padding: 24px;
|
|
92
|
+
text-align: center;
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
color: var(--secondary-info-fg, #aaa);
|
|
95
|
+
background: var(--bg-app-badge);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&__video {
|
|
99
|
+
width: 100%;
|
|
100
|
+
background: #000;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&__playback-error {
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
color: var(--secondary-info-fg, #aaa);
|
|
106
|
+
|
|
107
|
+
a {
|
|
108
|
+
color: var(--primary, #d297ff);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&__timeline {
|
|
113
|
+
position: relative;
|
|
114
|
+
height: 16px;
|
|
115
|
+
background: var(--bg-app-badge);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
&__marker {
|
|
119
|
+
position: absolute;
|
|
120
|
+
top: 2px;
|
|
121
|
+
width: 12px;
|
|
122
|
+
height: 12px;
|
|
123
|
+
border-radius: 50%;
|
|
124
|
+
background: var(--primary, #d297ff);
|
|
125
|
+
border: none;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
transform: translateX(-50%);
|
|
128
|
+
|
|
129
|
+
&--blocker { background: var(--danger, #e53935); }
|
|
130
|
+
&--major { background: #f39c12; }
|
|
131
|
+
&--minor { background: #95a5a6; }
|
|
132
|
+
&--note { background: var(--primary, #d297ff); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
&__marker-list {
|
|
136
|
+
list-style: none;
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
display: flex;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
gap: 6px;
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
|
|
144
|
+
li {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: 8px;
|
|
148
|
+
color: var(--secondary-info-fg, #aaa);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
&__marker-jump {
|
|
153
|
+
background: var(--bg-app-badge);
|
|
154
|
+
border: none;
|
|
155
|
+
color: var(--primary, #d297ff);
|
|
156
|
+
font-weight: 700;
|
|
157
|
+
padding: 2px 8px;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
flex-shrink: 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
</style>
|