feat(series): improve release popup navigation and controls

This commit is contained in:
2026-05-22 18:48:34 +00:00
parent a748adf87e
commit c3dee9bed9
4 changed files with 642 additions and 528 deletions
@@ -0,0 +1,393 @@
<template>
<div
class="version-row"
:class="{
'version-best': best,
'version-selectable': isSelectable,
'version-disabled': isDisabled,
'version-menu': variant === 'menu',
'version-with-actions': showActions,
}"
tabindex="0"
:title="resolvedTitle"
v-bind="$attrs"
@click="handleActivate"
@keydown.enter.prevent="handleActivate"
@keydown.space.prevent="handleActivate"
>
<div class="version-main">
<div class="version-badges">
<span v-if="torrent.resolution" class="v-badge res">{{ torrent.resolution }}</span>
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span>
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
<span v-if="showHdrBadge" class="v-badge hdr">HDR</span>
<span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span>
<span v-if="isDisc" class="v-disc">💿</span>
</div>
<div class="version-language-flags">
<LanguageFlags class="language-flags-audio" :codes="torrent.audio_languages" :compact="compactFlags" />
<span
v-if="hasLanguageDisplay(torrent.audio_languages) && hasLanguageDisplay(torrent.subtitle_languages)"
class="language-separator"
></span>
<LanguageFlags class="language-flags-subs" :codes="torrent.subtitle_languages" :compact="compactFlags" />
</div>
</div>
<div class="version-dolby-cell">
<DolbyBadges
class="version-dolby"
:has-dolby-vision="hasDolbyVision"
:has-dolby-atmos="hasDolbyAtmos"
:is-hdr="hasHdr"
/>
</div>
<div v-if="showActions" class="version-actions">
<button
class="ctx-btn ctx-btn-play"
tabindex="0"
@click.stop="emit('play')"
:disabled="!torrent.playable_file"
> {{ playLabel }}</button>
<button
class="ctx-btn ctx-btn-folder"
tabindex="0"
@click.stop="emit('openFolder')"
:disabled="!torrent.playable_file"
>📁</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Torrent } from '../types';
import LanguageFlags from './LanguageFlags.vue';
import DolbyBadges from './DolbyBadges.vue';
import { buildLanguageFlags } from '../utils/languageFlags';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<{
torrent: Torrent;
best?: boolean;
selectable?: boolean;
disabled?: boolean;
compactFlags?: boolean;
showActions?: boolean;
playLabel?: string;
title?: string;
variant?: 'default' | 'menu';
}>(), {
best: false,
selectable: undefined,
disabled: undefined,
compactFlags: false,
showActions: false,
playLabel: 'Play',
title: undefined,
variant: 'default',
});
const emit = defineEmits<{
activate: [MouseEvent | KeyboardEvent];
play: [];
openFolder: [];
}>();
const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i;
const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i;
const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i;
const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i;
const blurayTagPattern = /\bblu[\s.-]*ray\b/i;
function hasDolbyTag(value: string | null | undefined): boolean {
return Boolean(value && dolbyTagPattern.test(value));
}
function hasAnyTag(
pattern: RegExp,
...values: Array<string | null | undefined>
): boolean {
return values.some((value) => Boolean(value && pattern.test(value)));
}
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
const mapped = buildLanguageFlags(codes);
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0;
}
function hasHdrTag(value: string | null | undefined): boolean {
return Boolean(value && hdrPattern.test(value));
}
const hasDolbyVision = computed(() => {
return (
props.torrent.has_dolby_vision === true
|| hasAnyTag(dolbyVisionPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
);
});
const hasDolbyAtmos = computed(() => {
return (
props.torrent.has_dolby_atmos === true
|| hasAnyTag(dolbyAtmosPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
);
});
const hasHdr = computed(() => {
return (
props.torrent.is_hdr === true
|| hasAnyTag(hdrPattern, props.torrent.quality, props.torrent.codec, props.torrent.audio, props.torrent.title)
);
});
const displayQualityBadge = computed(() => {
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null;
if (blurayTagPattern.test(props.torrent.quality) && !isDisc.value) return null;
return props.torrent.quality;
});
const displayCodecBadge = computed(() => {
if (!props.torrent.codec || hasDolbyTag(props.torrent.codec)) return null;
return props.torrent.codec;
});
const displayAudioBadge = computed(() => {
if (!props.torrent.audio || hasDolbyTag(props.torrent.audio)) return null;
return props.torrent.audio;
});
const showHdrBadge = computed(() => {
if (!hasHdr.value) return false;
return !hasHdrTag(props.torrent.quality) && !hasHdrTag(props.torrent.codec) && !hasHdrTag(props.torrent.audio);
});
const isDisc = computed(() => {
if (!props.torrent.playable_file) return false;
const filename = props.torrent.playable_file.toLowerCase();
return filename.endsWith('index.bdmv') || filename.endsWith('.iso');
});
const isSelectable = computed(() => {
if (props.selectable !== undefined) return props.selectable;
return Boolean(props.torrent.playable_file);
});
const isDisabled = computed(() => {
if (props.disabled !== undefined) return props.disabled;
return !isSelectable.value;
});
const resolvedTitle = computed(() => {
if (props.title !== undefined) return props.title;
return isSelectable.value ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file';
});
function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!isSelectable.value || isDisabled.value) return;
emit('activate', event);
}
</script>
<style scoped>
.version-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: stretch;
column-gap: 8px;
row-gap: 6px;
padding: 10px 12px;
background: rgba(10, 14, 22, 0.2);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.2s, border-color 0.2s;
}
.version-row.version-menu {
padding: 10px 16px;
}
.version-row.version-with-actions {
grid-template-columns: minmax(0, 1fr) max-content max-content;
}
.version-row:hover {
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-row.version-best {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(10, 14, 22, 0.2);
}
.version-row.version-best:hover {
background: rgba(10, 14, 22, 0.28);
border-color: rgba(255, 255, 255, 0.2);
}
.version-row.version-selectable {
cursor: pointer;
}
.version-row.version-selectable:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 2px;
}
.version-row.version-disabled {
cursor: not-allowed;
opacity: 0.75;
}
.version-main {
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.version-dolby-cell {
grid-column: 2;
display: flex;
align-items: stretch;
justify-content: flex-end;
min-width: 0;
}
.version-dolby {
align-self: stretch;
}
.version-badges {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
}
.v-badge {
display: flex;
align-items: center;
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
.v-disc {
display: flex;
align-items: center;
font-size: 1.1rem;
line-height: 1;
}
.v-badge.res {
background: #1d4ed8;
color: #eff6ff;
}
.v-badge.qual {
background: #7c3aed;
color: #f5f3ff;
}
.v-badge.codec {
background: #0f766e;
color: #ecfeff;
}
.v-badge.audio {
background: #b45309;
color: #fffbeb;
}
.v-badge.hdr {
background: #166534;
color: #dcfce7;
}
.version-language-flags {
display: inline-flex;
align-items: center;
gap: 2px;
width: 100%;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.version-language-flags > .language-flags-audio {
flex: 0 0 auto;
}
.version-language-flags > .language-flags-subs {
flex: 1 1 auto;
min-width: 0;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
}
.version-language-flags > .language-flags-subs :deep(.language-flags) {
display: inline-flex;
max-width: 100%;
overflow: hidden;
}
.version-language-flags > .language-flags-subs :deep(.language-flag-list) {
width: max-content;
max-width: none;
overflow: hidden;
}
.language-separator {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
font-weight: 700;
line-height: 1;
margin: 0;
}
.version-actions {
grid-column: 3;
display: flex;
align-items: center;
gap: 6px;
}
.ctx-btn {
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.ctx-btn:hover:not(:disabled),
.ctx-btn.nav-focused:not(:disabled),
.ctx-btn:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.35);
outline: none;
}
.ctx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ctx-btn-folder {
width: 34px;
text-align: center;
padding: 6px 0;
}
</style>