Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,27 @@ onBeforeUnmount(() => {
<style scoped>
/* Absolutely centers the search box in the topbar (dead-center on every screen, independent of the
title/crumb on the left and the Docs/theme/Add cluster on the right). Also anchors the
absolute-positioned SearchResults dropdown directly under the box. */
absolute-positioned SearchResults dropdown directly under the box.

Centered with a negative half-width margin, NOT `transform: translateX(-50%)`. A transform makes an
element the containing block for its position:fixed descendants, which collapsed SearchResults'
`.sr-backdrop` (position:fixed; inset:0) from the viewport down to this 480x36 box — so clicking
anywhere outside the search box never dismissed the results. Keep this transform-free. */
.topbar-search {
--sr-w: min(480px, 90vw); /* the shared width: the input fills it, the results panel stretches to it */
position: absolute;
left: 50%;
transform: translateX(-50%);
width: min(480px, 90vw); /* the shared width: the input fills it, the results panel stretches to it */
margin-left: calc(var(--sr-w) / -2);
width: var(--sr-w);
display: flex;
align-items: center;
z-index: 1; /* keep the centered box above the flex siblings if they ever meet */
}
/* The input outranks the click-away backdrop (.sr-backdrop, z-index 90, a sibling in this same
stacking context) so clicking back into the box to edit the query moves the caret instead of
dismissing the results. Every other click still lands on the backdrop and closes them. */
.topbar-search > .search-input {
position: relative;
z-index: 92;
}
</style>
4 changes: 4 additions & 0 deletions src/components/SearchResults.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</template>

<style scoped>
/* Full-viewport click-away catcher. Relies on NO ancestor having a transform/filter/backdrop-filter:
any of those would become the containing block for this position:fixed element and shrink it to
their own box, silently killing click-to-dismiss. See the transform-free note on .topbar-search in
App.vue — the topbar's centering used to do exactly that. */
.sr-backdrop {
position: fixed;
inset: 0;
Expand Down
207 changes: 107 additions & 100 deletions src/screens/ActiveStreamsScreen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1169,124 +1169,131 @@ function onRailKey(e: KeyboardEvent): void {
</div>
</div>

<!-- Stream viewer slide-over -->
<div v-if="viewStream" class="stream-view-bg" @click="close">
<div class="stream-view" @click.stop>
<div class="stream-view-hd">
<ChannelLogo :ch="chOf(viewStream)" />
<div style="min-width: 0; flex: 1;">
<div class="row" style="gap: 8px;">
<span style="font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ chOf(viewStream).tvg_name }}</span>
<span v-if="viewStream.status !== 'bad'" class="live-pill"><span class="dot" />LIVE</span>
<Pill v-else tone="bad"><Icon name="warn" :size="11" />offline</Pill>
</div>
<div class="mono muted" style="font-size: var(--fs-xs); margin-top: 3px;">
#{{ chOf(viewStream).channelNo ?? '—' }} · {{ chOf(viewStream).group }} ·
{{ viewStream.status === 'bad' ? 'no signal' : (viewStream.resolution ?? '—') + ' · ' + viewStream.bitrate.toFixed(1) + ' Mbps' }}
</div>
</div>
<Btn variant="ghost" size="sm" icon="x" @click="close" title="Close (Esc)" />
</div>

<div class="stream-view-body">
<div class="player" style="border-radius: 12px;">
<template v-if="viewStream.status === 'bad'">
<div style="position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-2); font-size: 13px;">
<div style="text-align: center;">
<Icon name="warn" :size="32" />
<div style="margin-top: 12px; font-weight: 600; color: var(--text-1); font-size: 15px;">Stream offline</div>
<div class="mono" style="font-size: 11px; margin-top: 6px;">upstream unreachable</div>
</div>
</div>
</template>
<template v-else>
<div class="stripes" />
<div class="label mono">{{ viewTech?.resolution }} · {{ viewTech?.fps ?? '—' }}fps · {{ viewStream.bitrate.toFixed(1) }} Mbps</div>
<div v-if="!playing" class="play" @click="playing = true">
<div class="play-btn"><Icon name="play" :size="28" /></div>
<!-- Stream viewer slide-over.
Teleported to <body>: /active is a stage route, so this screen renders inside
.mq-stage-content, which is `position:relative; z-index:1` and therefore a stacking context —
it would clamp this overlay's z-index:85 down to 1, below .topbar (20), letting the topbar's
"Search everything…" box paint over the panel header (issue #55). At <body> the z-index
resolves against the root context and the panel sits above the bar, like every other drawer. -->
<Teleport to="body">
<div v-if="viewStream" class="stream-view-bg" @click="close">
<div class="stream-view" @click.stop>
<div class="stream-view-hd">
<ChannelLogo :ch="chOf(viewStream)" />
<div style="min-width: 0; flex: 1;">
<div class="row" style="gap: 8px;">
<span style="font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ chOf(viewStream).tvg_name }}</span>
<span v-if="viewStream.status !== 'bad'" class="live-pill"><span class="dot" />LIVE</span>
<Pill v-else tone="bad"><Icon name="warn" :size="11" />offline</Pill>
</div>
<div class="controls">
<button class="player-ctrl" @click="playing = !playing">
<Icon :name="playing ? 'pause' : 'play'" :size="14" />
</button>
<div class="track" />
<button class="player-ctrl" @click="muted = !muted">
<Icon :name="muted ? 'x' : 'check'" :size="13" />
</button>
<span class="mono" style="font-size: 11px;">LIVE</span>
<button class="player-ctrl" title="Fullscreen"><Icon name="grid" :size="13" /></button>
<div class="mono muted" style="font-size: var(--fs-xs); margin-top: 3px;">
#{{ chOf(viewStream).channelNo ?? '—' }} · {{ chOf(viewStream).group }} ·
{{ viewStream.status === 'bad' ? 'no signal' : (viewStream.resolution ?? '—') + ' · ' + viewStream.bitrate.toFixed(1) + ' Mbps' }}
</div>
</template>
</div>
<Btn variant="ghost" size="sm" icon="x" @click="close" title="Close (Esc)" />
</div>

<div v-if="viewStream.status !== 'bad'" class="card flush" style="background: var(--bg-2);">
<div class="card-hd" style="padding: 12px 14px;">
<h2 style="font-size: 13px;">From the guide</h2>
<span class="spacer" />
<span class="muted" style="font-size: var(--fs-xs);">EPG-matched</span>
</div>
<div :style="{ padding: '14px', display: 'grid', gridTemplateColumns: npData(viewStream.channelId).live && npData(viewStream.channelId).next ? '1fr 1fr' : '1fr', gap: '12px' }">
<div v-if="npData(viewStream.channelId).live"
style="padding: 10px 12px; border-radius: 8px; background: var(--accent-soft); border: 1px solid oklch(0.82 0.13 220 / 0.4);">
<div class="mono" style="font-size: 10px; letter-spacing: 0.08em; color: var(--accent-hi); font-weight: 600;">ON NOW</div>
<div style="font-weight: 600; font-size: 14px; margin-top: 4px; color: var(--accent-hi);">{{ npData(viewStream.channelId).live!.title }}</div>
<div class="mono muted" style="font-size: 11px; margin-top: 4px;">
{{ formatTime(npData(viewStream.channelId).live!.start) }}–{{ formatTime(npData(viewStream.channelId).live!.end) }} · {{ npData(viewStream.channelId).live!.cat }}
<div class="stream-view-body">
<div class="player" style="border-radius: 12px;">
<template v-if="viewStream.status === 'bad'">
<div style="position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-2); font-size: 13px;">
<div style="text-align: center;">
<Icon name="warn" :size="32" />
<div style="margin-top: 12px; font-weight: 600; color: var(--text-1); font-size: 15px;">Stream offline</div>
<div class="mono" style="font-size: 11px; margin-top: 6px;">upstream unreachable</div>
</div>
</div>
</template>
<template v-else>
<div class="stripes" />
<div class="label mono">{{ viewTech?.resolution }} · {{ viewTech?.fps ?? '—' }}fps · {{ viewStream.bitrate.toFixed(1) }} Mbps</div>
<div v-if="!playing" class="play" @click="playing = true">
<div class="play-btn"><Icon name="play" :size="28" /></div>
</div>
<div class="controls">
<button class="player-ctrl" @click="playing = !playing">
<Icon :name="playing ? 'pause' : 'play'" :size="14" />
</button>
<div class="track" />
<button class="player-ctrl" @click="muted = !muted">
<Icon :name="muted ? 'x' : 'check'" :size="13" />
</button>
<span class="mono" style="font-size: 11px;">LIVE</span>
<button class="player-ctrl" title="Fullscreen"><Icon name="grid" :size="13" /></button>
</div>
</template>
</div>

<div v-if="viewStream.status !== 'bad'" class="card flush" style="background: var(--bg-2);">
<div class="card-hd" style="padding: 12px 14px;">
<h2 style="font-size: 13px;">From the guide</h2>
<span class="spacer" />
<span class="muted" style="font-size: var(--fs-xs);">EPG-matched</span>
</div>
<div v-if="npData(viewStream.channelId).next"
style="padding: 10px 12px; border-radius: 8px; background: var(--bg-3); border: 1px solid var(--hairline);">
<div class="mono" style="font-size: 10px; letter-spacing: 0.08em; color: var(--text-2); font-weight: 600;">UP NEXT</div>
<div style="font-weight: 600; font-size: 14px; margin-top: 4px; color: var(--text-0);">{{ npData(viewStream.channelId).next!.title }}</div>
<div class="mono muted" style="font-size: 11px; margin-top: 4px;">
{{ formatTime(npData(viewStream.channelId).next!.start) }}–{{ formatTime(npData(viewStream.channelId).next!.end) }} · {{ npData(viewStream.channelId).next!.cat }}
<div :style="{ padding: '14px', display: 'grid', gridTemplateColumns: npData(viewStream.channelId).live && npData(viewStream.channelId).next ? '1fr 1fr' : '1fr', gap: '12px' }">
<div v-if="npData(viewStream.channelId).live"
style="padding: 10px 12px; border-radius: 8px; background: var(--accent-soft); border: 1px solid oklch(0.82 0.13 220 / 0.4);">
<div class="mono" style="font-size: 10px; letter-spacing: 0.08em; color: var(--accent-hi); font-weight: 600;">ON NOW</div>
<div style="font-weight: 600; font-size: 14px; margin-top: 4px; color: var(--accent-hi);">{{ npData(viewStream.channelId).live!.title }}</div>
<div class="mono muted" style="font-size: 11px; margin-top: 4px;">
{{ formatTime(npData(viewStream.channelId).live!.start) }}–{{ formatTime(npData(viewStream.channelId).live!.end) }} · {{ npData(viewStream.channelId).live!.cat }}
</div>
</div>
<div v-if="npData(viewStream.channelId).next"
style="padding: 10px 12px; border-radius: 8px; background: var(--bg-3); border: 1px solid var(--hairline);">
<div class="mono" style="font-size: 10px; letter-spacing: 0.08em; color: var(--text-2); font-weight: 600;">UP NEXT</div>
<div style="font-weight: 600; font-size: 14px; margin-top: 4px; color: var(--text-0);">{{ npData(viewStream.channelId).next!.title }}</div>
<div class="mono muted" style="font-size: 11px; margin-top: 4px;">
{{ formatTime(npData(viewStream.channelId).next!.start) }}–{{ formatTime(npData(viewStream.channelId).next!.end) }} · {{ npData(viewStream.channelId).next!.cat }}
</div>
</div>
</div>
</div>
</div>

<div class="metric-grid" style="grid-template-columns: repeat(4, 1fr);">
<div class="metric"><div class="lbl">Viewers</div><div class="val" style="font-size: 17px;">{{ viewStream.viewers }}</div></div>
<div class="metric"><div class="lbl">Bitrate</div><div class="val" style="font-size: 17px;">{{ viewStream.status === 'bad' ? '—' : viewStream.bitrate.toFixed(1) + ' Mbps' }}</div></div>
<div class="metric"><div class="lbl">Bandwidth</div><div class="val" style="font-size: 17px;">{{ viewStream.bandwidth }} Mbps</div></div>
<div class="metric"><div class="lbl">Uptime</div><div class="val" style="font-size: 17px;">{{ viewStream.uptime }}</div></div>
</div>

<div class="card flush" style="background: var(--bg-2);">
<div class="card-hd" style="padding: 12px 14px;">
<h2 style="font-size: 13px;">Stream details</h2>
<span class="spacer" />
<Pill :tone="viewStream.status === 'bad' ? 'bad' : viewStream.status === 'warn' ? 'warn' : 'good'">
<StatusDot :status="viewStream.status" :pulse="viewStream.status !== 'bad'" />
{{ viewStream.status === 'bad' ? 'offline' : viewStream.status === 'warn' ? viewStream.phase : 'healthy' }}
</Pill>
<div class="metric-grid" style="grid-template-columns: repeat(4, 1fr);">
<div class="metric"><div class="lbl">Viewers</div><div class="val" style="font-size: 17px;">{{ viewStream.viewers }}</div></div>
<div class="metric"><div class="lbl">Bitrate</div><div class="val" style="font-size: 17px;">{{ viewStream.status === 'bad' ? '—' : viewStream.bitrate.toFixed(1) + ' Mbps' }}</div></div>
<div class="metric"><div class="lbl">Bandwidth</div><div class="val" style="font-size: 17px;">{{ viewStream.bandwidth }} Mbps</div></div>
<div class="metric"><div class="lbl">Uptime</div><div class="val" style="font-size: 17px;">{{ viewStream.uptime }}</div></div>
</div>
<div style="padding: 14px;">
<div class="kv-list">
<div class="k">Video</div><div class="v mono">{{ viewTech?.video }}</div>
<div class="k">Audio</div><div class="v mono">{{ viewTech?.audio }}</div>
<div class="k">Container</div><div class="v mono">{{ viewTech?.container }}</div>
<div class="k">Delivery</div><div class="v mono">{{ deliveryLabel(viewStream.delivery) }}</div>
<div class="k">Resolution</div><div class="v mono">{{ viewTech?.resolution }}<template v-if="viewTech?.fps"> @ {{ viewTech?.fps }}fps</template></div>
<div class="k">Bandwidth</div><div class="v mono">{{ viewStream.bandwidth }} Mbps egress</div>
<div class="k">TVG-ID</div>
<div class="v mono">
<template v-if="chOf(viewStream).tvg_id">{{ chOf(viewStream).tvg_id }}</template>
<span v-else style="color: var(--text-3);">—</span>

<div class="card flush" style="background: var(--bg-2);">
<div class="card-hd" style="padding: 12px 14px;">
<h2 style="font-size: 13px;">Stream details</h2>
<span class="spacer" />
<Pill :tone="viewStream.status === 'bad' ? 'bad' : viewStream.status === 'warn' ? 'warn' : 'good'">
<StatusDot :status="viewStream.status" :pulse="viewStream.status !== 'bad'" />
{{ viewStream.status === 'bad' ? 'offline' : viewStream.status === 'warn' ? viewStream.phase : 'healthy' }}
</Pill>
</div>
<div style="padding: 14px;">
<div class="kv-list">
<div class="k">Video</div><div class="v mono">{{ viewTech?.video }}</div>
<div class="k">Audio</div><div class="v mono">{{ viewTech?.audio }}</div>
<div class="k">Container</div><div class="v mono">{{ viewTech?.container }}</div>
<div class="k">Delivery</div><div class="v mono">{{ deliveryLabel(viewStream.delivery) }}</div>
<div class="k">Resolution</div><div class="v mono">{{ viewTech?.resolution }}<template v-if="viewTech?.fps"> @ {{ viewTech?.fps }}fps</template></div>
<div class="k">Bandwidth</div><div class="v mono">{{ viewStream.bandwidth }} Mbps egress</div>
<div class="k">TVG-ID</div>
<div class="v mono">
<template v-if="chOf(viewStream).tvg_id">{{ chOf(viewStream).tvg_id }}</template>
<span v-else style="color: var(--text-3);">—</span>
</div>
<div class="k">Source</div>
<div class="v"><Pill tone="cyan">{{ chOf(viewStream).source }}</Pill></div>
</div>
<div class="k">Source</div>
<div class="v"><Pill tone="cyan">{{ chOf(viewStream).source }}</Pill></div>
</div>
</div>
</div>

<div class="row" style="gap: 8px;">
<Btn variant="ghost" icon="edit">Edit channel</Btn>
<span class="spacer" />
<div class="row" style="gap: 8px;">
<Btn variant="ghost" icon="edit">Edit channel</Btn>
<span class="spacer" />
</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>

Expand Down
14 changes: 12 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,14 @@ button, input, textarea, select { outline: none; }
background: radial-gradient(125% 105% at 50% 36%, transparent 60%, rgb(0 0 0 / 0.06) 100%);
}
/* the scrolling content of a stage screen sits inset above the field;
the gradient itself stays edge-to-edge (no padding on .screen). */
the gradient itself stays edge-to-edge (no padding on .screen).

LANDMINE: `position: relative` + `z-index: 1` makes this a STACKING CONTEXT. Any fixed-position
overlay rendered from inside a stage screen (dashboard, active) is clamped to level 1 here and
therefore paints BELOW .topbar (z-index 20) — see issue #55, where the topbar's "Search
everything…" box covered the Active Streams slide-over's header. Overlays inside a stage screen
MUST `<Teleport to="body">`. (The z-index itself is load-bearing for the .mq-stage::before
vignette — keep it.) */
.mq-stage > .mq-stage-content {
position: relative;
z-index: 1;
Expand Down Expand Up @@ -2740,7 +2747,10 @@ textarea.input { height: auto; padding: 10px; min-height: 80px; resize: vertical
.status-badge-slot > .live-pill .dot { width: 8px; height: 8px; }
.status-badge-slot > .pill .ico { width: 12px; height: 12px; flex: none; }

/* Stream view slide-over (1/2 screen) */
/* Stream view slide-over (1/2 screen).
z-index 85 only resolves if this overlay is NOT inside a stacking context. ActiveStreamsScreen is a
stage route, so it must `<Teleport to="body">` its copy (issue #55); EPGDetailScreen is not, so its
copy renders in place. See the .mq-stage-content LANDMINE note above. */
.stream-view-bg {
position: fixed;
inset: 0;
Expand Down
Loading