Skip to content

Commit 07d5f7b

Browse files
authored
feature: live viewers (#394)
1 parent 50f5686 commit 07d5f7b

9 files changed

Lines changed: 183 additions & 161 deletions

File tree

components/StreamGlobal.vue

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import StreamEmbed from "~/components/StreamEmbed.vue";
33
import LiveStreamPlayer from "~/components/match/LiveStreamPlayer.vue";
44
import { Cross2Icon } from "@radix-icons/vue";
5-
import { ArrowUpRight, ExternalLink } from "lucide-vue-next";
5+
import { ArrowUpRight } from "lucide-vue-next";
66
</script>
77

88
<template>
@@ -30,18 +30,8 @@ import { ArrowUpRight, ExternalLink } from "lucide-vue-next";
3030
<div class="w-3 h-3 border-l-2 border-t-2 border-foreground/40"></div>
3131
</div>
3232
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
33-
<button
34-
v-if="isGameStreamer && stream.match_id"
35-
type="button"
36-
class="w-6 h-6 rounded-sm opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background border border-border flex items-center justify-center"
37-
:title="$t('match.open_popout')"
38-
@click="openPopoutWindow"
39-
>
40-
<ExternalLink class="w-4 h-4" />
41-
<span class="sr-only">{{ $t("match.open_popout") }}</span>
42-
</button>
4333
<NuxtLink
44-
v-if="stream.match_id"
34+
v-if="stream.match_id && !isOnMatchPage"
4535
:to="`/matches/${stream.match_id}`"
4636
class="w-6 h-6 rounded-sm opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background border border-border flex items-center justify-center"
4737
:title="$t('match.open_match')"
@@ -85,6 +75,14 @@ export default {
8575
isGameStreamer() {
8676
return (this.stream as any)?.is_game_streamer === true;
8777
},
78+
isOnMatchPage() {
79+
const matchId = (this.stream as any)?.match_id;
80+
if (!matchId) return false;
81+
return (
82+
this.$route.name === "matches-id" &&
83+
String(this.$route.params.id) === String(matchId)
84+
);
85+
},
8886
containerStyle() {
8987
return {
9088
width: `${this.width}px`,
@@ -125,18 +123,6 @@ export default {
125123
closePreview() {
126124
useApplicationSettingsStore().setGlobalStream();
127125
},
128-
openPopoutWindow() {
129-
const matchId = (this.stream as any)?.match_id;
130-
if (!matchId || typeof window === "undefined") {
131-
return;
132-
}
133-
window.open(
134-
`/match-popout/${matchId}`,
135-
`match-popout-${matchId}`,
136-
"popup=yes,width=960,height=640,resizable=yes,scrollbars=no",
137-
);
138-
this.closePreview();
139-
},
140126
startResize(e: MouseEvent) {
141127
this.isResizing = true;
142128
this.resizeStart = {

components/match/LiveStreamFeatureCard.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import TwitchIcon from "~/components/icons/TwitchIcon.vue";
55
import YouTubeIcon from "~/components/icons/YouTubeIcon.vue";
66
import KickIcon from "~/components/icons/KickIcon.vue";
77
import MatchTableRow from "~/components/MatchTableRow.vue";
8+
import StreamViewerBadge from "~/components/match/StreamViewerBadge.vue";
89
910
type Stream = {
1011
id: string;
@@ -173,6 +174,10 @@ function onWatchClick(e: Event) {
173174
+{{ extraStreamCount }}
174175
</div>
175176

177+
<div class="absolute bottom-2 left-2">
178+
<StreamViewerBadge :match-id="match.id" size="md" />
179+
</div>
180+
176181
<button
177182
type="button"
178183
:title="$t('match.stream.watch')"

components/match/LiveStreamPlayer.vue

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ExternalLink, PictureInPicture } from "lucide-vue-next";
88
import { generateSubscription } from "~/graphql/graphqlGen";
99
import StreamCanvas from "~/components/match/StreamCanvas.vue";
1010
import MatchScoreboardOverlay from "~/components/match/MatchScoreboardOverlay.vue";
11+
import StreamViewerBadge from "~/components/match/StreamViewerBadge.vue";
1112
import { useApplicationSettingsStore } from "~/stores/ApplicationSettings";
1213
import { useAuthStore } from "~/stores/AuthStore";
1314
import { useStreamerStore } from "~/stores/StreamerStore";
@@ -136,7 +137,12 @@ function teardownLocalSubscription() {
136137
localStream.value = null;
137138
}
138139
140+
const coarsePointer = ref(false);
141+
139142
onMounted(() => {
143+
if (typeof window !== "undefined" && window.matchMedia) {
144+
coarsePointer.value = window.matchMedia("(pointer: coarse)").matches;
145+
}
140146
if (shouldRunLocalSubscription()) ensureLocalSubscription();
141147
});
142148
@@ -237,10 +243,16 @@ function openPopoutWindow() {
237243
/>
238244

239245
<div
240-
v-if="isLive && !inGlobal && !inPopout"
241-
class="absolute bottom-3 left-3 z-10 flex items-center gap-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150"
246+
v-if="isLive && !inPopout"
247+
class="absolute bottom-3 left-12 z-10 flex items-center gap-2 transition-opacity duration-150"
248+
:class="
249+
coarsePointer
250+
? ''
251+
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
252+
"
242253
>
243254
<button
255+
v-if="!inGlobal"
244256
type="button"
245257
:title="$t('ui.pin_overlay')"
246258
:aria-label="$t('ui.pin_overlay')"
@@ -250,6 +262,7 @@ function openPopoutWindow() {
250262
<PictureInPicture class="size-3.5" />
251263
</button>
252264
<button
265+
v-if="!coarsePointer"
253266
type="button"
254267
:title="$t('ui.open_new_window')"
255268
:aria-label="$t('ui.open_new_window')"
@@ -258,6 +271,7 @@ function openPopoutWindow() {
258271
>
259272
<ExternalLink class="size-3.5" />
260273
</button>
274+
<StreamViewerBadge :match-id="matchId" size="md" />
261275
</div>
262276
</StreamCanvas>
263277
</div>

components/match/MatchLiveStreams.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import Empty from "@/components/ui/empty/Empty.vue";
3131
import { e_player_roles_enum } from "~/generated/zeus";
3232
import StreamEmbed from "~/components/StreamEmbed.vue";
3333
import LiveStreamPlayer from "~/components/match/LiveStreamPlayer.vue";
34+
import StreamViewerBadge from "~/components/match/StreamViewerBadge.vue";
3435
</script>
3536

3637
<template>
@@ -137,6 +138,10 @@ import LiveStreamPlayer from "~/components/match/LiveStreamPlayer.vue";
137138
>
138139
{{ $t("streams.live_badge") }}
139140
</Badge>
141+
<StreamViewerBadge
142+
v-if="stream.is_game_streamer && stream.is_live"
143+
:match-id="match.id"
144+
/>
140145
<!-- Mode badge — Direct (live) has no GOTV delay,
141146
TV honors `tv_delay`. Always shown for the
142147
system row so operators can tell the two
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import { Eye } from "lucide-vue-next";
4+
import { useStreamViewers } from "~/composables/useStreamViewers";
5+
6+
const props = withDefaults(
7+
defineProps<{
8+
matchId: string;
9+
size?: "sm" | "md";
10+
}>(),
11+
{ size: "sm" },
12+
);
13+
14+
const { getCount } = useStreamViewers();
15+
const count = getCount(props.matchId);
16+
17+
const formatted = computed(() => {
18+
const n = count.value;
19+
if (n === null || n < 0) return "0";
20+
if (n >= 10_000) return `${(n / 1000).toFixed(0)}K`;
21+
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
22+
return String(n);
23+
});
24+
25+
const sizeClasses = computed(() =>
26+
props.size === "md"
27+
? "text-xs px-3 py-1.5 gap-1.5 leading-none"
28+
: "text-[10px] px-1.5 py-0 gap-1",
29+
);
30+
31+
const iconClasses = computed(() =>
32+
props.size === "md" ? "h-3.5 w-3.5" : "h-3 w-3",
33+
);
34+
</script>
35+
36+
<template>
37+
<span
38+
class="inline-flex items-center rounded border border-white/20 bg-black/60 font-medium text-white backdrop-blur-sm"
39+
:class="sizeClasses"
40+
:title="`${count ?? 0} watching`"
41+
>
42+
<Eye :class="iconClasses" />
43+
{{ formatted }}
44+
</span>
45+
</template>

components/match/WhepPlayer.vue

Lines changed: 10 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
<script setup lang="ts">
22
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
3-
import {
4-
Volume2,
5-
VolumeX,
6-
Maximize2,
7-
Minimize2,
8-
PictureInPicture,
9-
} from "lucide-vue-next";
3+
import { Volume2, VolumeX, Maximize2, Minimize2 } from "lucide-vue-next";
104
import { useClipRenderActive } from "~/composables/useClipRenderActive";
115
126
const { active: clipRenderActive } = useClipRenderActive();
@@ -137,66 +131,6 @@ function onWebkitEndFullscreen() {
137131
}
138132
139133
// Picture-in-Picture — only surfaced on mobile for live game streams.
140-
// Demo/highlights set `enablePip=false` and keep `disablepictureinpicture`
141-
// on the <video>, which is what Chrome reads when deciding whether to
142-
// honor a PIP request.
143-
const isPip = ref(false);
144-
const showPip = computed(() => {
145-
if (!props.enablePip) return false;
146-
if (!coarsePointer.value) return false;
147-
if (typeof document === "undefined") return false;
148-
const doc = document as Document & { pictureInPictureEnabled?: boolean };
149-
const video = videoRef.value as IosVideo | null;
150-
return !!(
151-
doc.pictureInPictureEnabled ||
152-
video?.webkitSupportsPresentationMode?.("picture-in-picture")
153-
);
154-
});
155-
156-
async function togglePip() {
157-
const video = videoRef.value as IosVideo | null;
158-
if (!video) return;
159-
const doc = document as Document & {
160-
pictureInPictureElement?: Element | null;
161-
exitPictureInPicture?: () => Promise<void>;
162-
};
163-
try {
164-
// iOS Safari path: standard requestPictureInPicture is shipped on
165-
// iPadOS 14+ but iPhone Safari still routes everything through the
166-
// webkit presentation-mode API.
167-
if (
168-
video.webkitSupportsPresentationMode?.("picture-in-picture") &&
169-
typeof video.webkitSetPresentationMode === "function" &&
170-
!doc.pictureInPictureEnabled
171-
) {
172-
const next =
173-
video.webkitPresentationMode === "picture-in-picture"
174-
? "inline"
175-
: "picture-in-picture";
176-
video.webkitSetPresentationMode(next);
177-
return;
178-
}
179-
if (doc.pictureInPictureElement) {
180-
await doc.exitPictureInPicture?.();
181-
} else {
182-
await video.requestPictureInPicture();
183-
}
184-
} catch (err) {
185-
console.debug("[whep] pip toggle failed:", err);
186-
}
187-
}
188-
189-
function onEnterPip() {
190-
isPip.value = true;
191-
}
192-
function onLeavePip() {
193-
isPip.value = false;
194-
}
195-
function onWebkitPresentationModeChanged() {
196-
const video = videoRef.value as IosVideo | null;
197-
isPip.value = video?.webkitPresentationMode === "picture-in-picture";
198-
}
199-
200134
function onKeyDown(e: KeyboardEvent) {
201135
if (e.metaKey || e.ctrlKey || e.altKey) return;
202136
const target = e.target;
@@ -504,12 +438,6 @@ onMounted(() => {
504438
// instead of the document fullscreenchange path.
505439
video.addEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
506440
video.addEventListener("webkitendfullscreen", onWebkitEndFullscreen);
507-
video.addEventListener("enterpictureinpicture", onEnterPip);
508-
video.addEventListener("leavepictureinpicture", onLeavePip);
509-
video.addEventListener(
510-
"webkitpresentationmodechanged",
511-
onWebkitPresentationModeChanged,
512-
);
513441
video.addEventListener("pause", onInvoluntaryPause);
514442
}
515443
});
@@ -550,12 +478,6 @@ onBeforeUnmount(() => {
550478
if (video) {
551479
video.removeEventListener("webkitbeginfullscreen", onWebkitBeginFullscreen);
552480
video.removeEventListener("webkitendfullscreen", onWebkitEndFullscreen);
553-
video.removeEventListener("enterpictureinpicture", onEnterPip);
554-
video.removeEventListener("leavepictureinpicture", onLeavePip);
555-
video.removeEventListener(
556-
"webkitpresentationmodechanged",
557-
onWebkitPresentationModeChanged,
558-
);
559481
video.removeEventListener("pause", onInvoluntaryPause);
560482
}
561483
});
@@ -646,10 +568,7 @@ defineExpose({ connect, teardown });
646568
</span>
647569
</button>
648570

649-
<!-- Audio + fullscreen tray (visible only when audio is on).
650-
On coarse pointers (touch) drop the hover-gated opacity so the
651-
tray stays reachable — hover is a flaky proxy for "is the
652-
viewer engaged" on phones and tablets. -->
571+
<!-- Audio tray (right) — visible only when audio is on. -->
653572
<div
654573
v-if="status === 'playing' && !useFallback && !isMuted"
655574
class="absolute bottom-2 right-2 z-10 flex items-center gap-2 transition-opacity duration-150"
@@ -679,43 +598,18 @@ defineExpose({ connect, teardown });
679598
@input="setVolume(Number(($event.target as HTMLInputElement).value))"
680599
/>
681600
</div>
682-
<button
683-
v-if="showPip"
684-
type="button"
685-
:aria-label="
686-
isPip
687-
? $t('ui_extras.exit_picture_in_picture')
688-
: $t('ui_extras.picture_in_picture')
689-
"
690-
:title="$t('replay_extras.picture_in_picture')"
691-
class="inline-flex size-7 items-center justify-center rounded-full border border-white/20 bg-black/60 text-white/90 backdrop-blur-sm transition-all duration-150 hover:bg-black/80 hover:text-white hover:scale-110 cursor-pointer"
692-
@click="togglePip"
693-
>
694-
<PictureInPicture class="size-3.5" />
695-
</button>
696-
<button
697-
type="button"
698-
:aria-label="
699-
isFullscreen
700-
? $t('ui_extras.exit_fullscreen')
701-
: $t('ui_extras.fullscreen')
702-
"
703-
:title="$t('replay_extras.fullscreen_key')"
704-
class="inline-flex size-7 items-center justify-center rounded-full border border-white/20 bg-black/60 text-white/90 backdrop-blur-sm transition-all duration-150 hover:bg-black/80 hover:text-white hover:scale-110 cursor-pointer"
705-
@click="toggleFullscreen"
706-
>
707-
<Minimize2 v-if="isFullscreen" class="size-3.5" />
708-
<Maximize2 v-else class="size-3.5" />
709-
</button>
710601
</div>
711602

712-
<!-- Always-available fullscreen + PIP entry, bottom-left while the
713-
unmute pill is occupying the bottom-right corner. Touch devices
714-
keep these buttons visible (no hover gating). -->
603+
<!-- Fullscreen — single left-side button. Hover-gated on desktop;
604+
always visible on touch devices. -->
715605
<div
716-
v-if="status === 'playing' && !useFallback && isMuted"
606+
v-if="status === 'playing' && !useFallback"
717607
class="absolute bottom-3 left-3 z-10 flex items-center gap-2 transition-opacity duration-150"
718-
:class="coarsePointer ? '' : 'opacity-0 group-hover:opacity-100'"
608+
:class="
609+
coarsePointer
610+
? ''
611+
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
612+
"
719613
>
720614
<button
721615
type="button"
@@ -731,20 +625,6 @@ defineExpose({ connect, teardown });
731625
<Minimize2 v-if="isFullscreen" class="size-3.5" />
732626
<Maximize2 v-else class="size-3.5" />
733627
</button>
734-
<button
735-
v-if="showPip"
736-
type="button"
737-
:aria-label="
738-
isPip
739-
? $t('ui_extras.exit_picture_in_picture')
740-
: $t('ui_extras.picture_in_picture')
741-
"
742-
:title="$t('replay_extras.picture_in_picture')"
743-
class="inline-flex size-7 items-center justify-center rounded-full border border-white/20 bg-black/60 text-white/90 backdrop-blur-sm transition-all duration-150 hover:bg-black/80 hover:text-white hover:scale-110 cursor-pointer"
744-
@click="togglePip"
745-
>
746-
<PictureInPicture class="size-3.5" />
747-
</button>
748628
</div>
749629
<!-- "Rendering clip" chip. Shown over the last-good frame while
750630
the pod has stopped its publisher to free the GPU for a

0 commit comments

Comments
 (0)