656 lines
16 KiB
Vue
656 lines
16 KiB
Vue
<template>
|
|
<div
|
|
class="version-row"
|
|
:class="{
|
|
'version-best': best,
|
|
'version-selectable': isSelectable && !inertCard,
|
|
'version-disabled': isDisabled,
|
|
'version-menu': variant === 'menu',
|
|
'version-with-actions': showActions,
|
|
'version-inert': inertCard,
|
|
}"
|
|
:tabindex="inertCard ? undefined : 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="showHdrBadge" class="v-badge hdr">HDR</span>
|
|
<span v-if="displayCodecBadge" class="v-badge codec">{{ displayCodecBadge }}</span>
|
|
<span v-if="displayQualityBadge" class="v-badge qual">{{ displayQualityBadge }}</span>
|
|
<span v-if="displayAudioBadge" class="v-badge audio">{{ displayAudioBadge }}</span>
|
|
<span v-if="displayReleaseGroupBadge" class="v-badge group">{{
|
|
displayReleaseGroupBadge
|
|
}}</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"
|
|
:external-codes="torrent.external_subtitle_languages"
|
|
:compact="compactFlags"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="version-dolby-cell">
|
|
<img v-if="showBlurayLogo" class="version-disc-logo" :src="blurayLogoUrl" alt="Blu-ray" />
|
|
<img v-else-if="showDvdLogo" class="version-disc-logo" :src="dvdLogoUrl" alt="DVD" />
|
|
<img
|
|
v-if="streamingServiceLogo"
|
|
class="version-service-logo"
|
|
:src="streamingServiceLogo.src"
|
|
:alt="streamingServiceLogo.alt"
|
|
:title="streamingServiceLogo.alt"
|
|
/>
|
|
<img
|
|
v-if="showHdr10PlusLogo"
|
|
class="version-hdr10plus-logo"
|
|
:src="hdr10plusLogoUrl"
|
|
alt="HDR10+"
|
|
/>
|
|
<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"
|
|
:title="playLabel"
|
|
>
|
|
▶
|
|
</button>
|
|
<button
|
|
class="ctx-btn ctx-btn-folder"
|
|
tabindex="0"
|
|
@click.stop="emit('openFolder')"
|
|
:disabled="!torrent.playable_file"
|
|
title="Open Folder"
|
|
>
|
|
📁
|
|
</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"
|
|
import blurayLogoUrl from "../assets/bluray.webp"
|
|
import dvdLogoUrl from "../assets/dvd.webp"
|
|
import amazonLogoUrl from "../assets/service-amazon.webp"
|
|
import appleTvLogoUrl from "../assets/service-apple-tv.webp"
|
|
import netflixLogoUrl from "../assets/service-netflix.webp"
|
|
import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
|
|
import huluLogoUrl from "../assets/service-hulu.webp"
|
|
import disneyLogoUrl from "../assets/service-disney.svg"
|
|
import itunesLogoUrl from "../assets/service-itunes.png"
|
|
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
|
|
|
|
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"
|
|
/** When true, the card itself is not interactive (no tabindex, no click/keyboard handlers).
|
|
* Use with showActions to make only the inline buttons interactive. */
|
|
inertCard?: boolean
|
|
}>(),
|
|
{
|
|
best: false,
|
|
selectable: undefined,
|
|
disabled: undefined,
|
|
compactFlags: false,
|
|
showActions: false,
|
|
playLabel: "Play",
|
|
title: undefined,
|
|
variant: "default",
|
|
inertCard: false,
|
|
},
|
|
)
|
|
|
|
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
|
|
const blurayPlayablePattern = /(?:^|[\\/])(movieobject|index)\.bdmv$/i
|
|
const dvdPlayablePattern = /(?:^|[\\/])video_ts\.ifo$/i
|
|
const webQualityPattern = /^web(?:[ .-]?dl|[ .-]?rip)$/i
|
|
|
|
const serviceLogoMap: Array<{ aliases: string[]; src: string; alt: string }> = [
|
|
{
|
|
aliases: ["amazon studios", "amazon prime video", "prime video", "amazon", "amzn"],
|
|
src: amazonLogoUrl,
|
|
alt: "Amazon Prime Video",
|
|
},
|
|
{
|
|
aliases: ["apple tv+", "apple tv plus", "apple tv", "atvp"],
|
|
src: appleTvLogoUrl,
|
|
alt: "Apple TV+",
|
|
},
|
|
{ aliases: ["itunes", "it"], src: itunesLogoUrl, alt: "iTunes" },
|
|
{ aliases: ["netflix", "nf", "nflx"], src: netflixLogoUrl, alt: "Netflix" },
|
|
{ aliases: ["hbo max", "max", "hmax"], src: hboMaxLogoUrl, alt: "HBO Max" },
|
|
{
|
|
aliases: ["disney plus", "disney+", "disney plus hotstar", "dsnp"],
|
|
src: disneyLogoUrl,
|
|
alt: "Disney+",
|
|
},
|
|
{ aliases: ["hulu"], src: huluLogoUrl, alt: "Hulu" },
|
|
]
|
|
|
|
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))
|
|
}
|
|
|
|
function normalizeProviderName(value: string | null | undefined): string {
|
|
return (value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9+]+/g, " ")
|
|
.trim()
|
|
}
|
|
|
|
function normalizeQualityBadge(value: string): string {
|
|
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
|
|
|
if (
|
|
normalized === "hdtv" ||
|
|
normalized === "pdtv" ||
|
|
normalized === "tvrip" ||
|
|
normalized === "sdtv"
|
|
) {
|
|
return "TV"
|
|
}
|
|
|
|
if (
|
|
normalized.includes("cam") ||
|
|
normalized === "telesync" ||
|
|
normalized === "ts" ||
|
|
normalized === "hdts" ||
|
|
normalized === "telecine" ||
|
|
normalized === "tc"
|
|
) {
|
|
return "CAM"
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
const hasDolbyVision = computed(() => {
|
|
return (
|
|
props.torrent.dovi === true ||
|
|
hasAnyTag(
|
|
dolbyVisionPattern,
|
|
props.torrent.quality,
|
|
props.torrent.codec,
|
|
props.torrent.audio,
|
|
props.torrent.title,
|
|
)
|
|
)
|
|
})
|
|
|
|
const hasDolbyAtmos = computed(() => {
|
|
return (
|
|
props.torrent.atmos === true ||
|
|
hasAnyTag(
|
|
dolbyAtmosPattern,
|
|
props.torrent.quality,
|
|
props.torrent.codec,
|
|
props.torrent.audio,
|
|
props.torrent.title,
|
|
)
|
|
)
|
|
})
|
|
|
|
const hasHdr = computed(() => {
|
|
return (
|
|
props.torrent.hdr === true ||
|
|
hasAnyTag(
|
|
hdrPattern,
|
|
props.torrent.quality,
|
|
props.torrent.codec,
|
|
props.torrent.audio,
|
|
props.torrent.title,
|
|
)
|
|
)
|
|
})
|
|
|
|
const isBlurayDisc = computed(() => {
|
|
if (!props.torrent.playable_file) return false
|
|
return blurayPlayablePattern.test(props.torrent.playable_file)
|
|
})
|
|
|
|
const isDvdDisc = computed(() => {
|
|
if (!props.torrent.playable_file) return false
|
|
return dvdPlayablePattern.test(props.torrent.playable_file)
|
|
})
|
|
|
|
const showBlurayLogo = computed(() => {
|
|
return isBlurayDisc.value
|
|
})
|
|
|
|
const showDvdLogo = computed(() => {
|
|
return isDvdDisc.value
|
|
})
|
|
|
|
const streamingServiceLogo = computed(() => {
|
|
if (!props.torrent.quality || !webQualityPattern.test(props.torrent.quality)) {
|
|
return null
|
|
}
|
|
|
|
const network = normalizeProviderName(props.torrent.network)
|
|
if (!network) return null
|
|
|
|
const found = serviceLogoMap.find((entry) => entry.aliases.includes(network))
|
|
return found ? { src: found.src, alt: found.alt } : null
|
|
})
|
|
|
|
const displayQualityBadge = computed(() => {
|
|
if (!props.torrent.quality || hasDolbyTag(props.torrent.quality)) return null
|
|
if (streamingServiceLogo.value) return null
|
|
if (blurayTagPattern.test(props.torrent.quality)) return null
|
|
return normalizeQualityBadge(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 displayReleaseGroupBadge = computed(() => {
|
|
const value = props.torrent.encoder?.trim()
|
|
if (!value) return null
|
|
return value
|
|
})
|
|
|
|
const showHdrBadge = computed(() => {
|
|
if (!hasHdr.value) return false
|
|
return (
|
|
!hasHdrTag(props.torrent.quality) &&
|
|
!hasHdrTag(props.torrent.codec) &&
|
|
!hasHdrTag(props.torrent.audio)
|
|
)
|
|
})
|
|
|
|
const hdr10PlusPattern = /hdr10\+|hdr10plus/i
|
|
|
|
const hasHdr10Plus = computed(() => {
|
|
if (props.torrent.hdr10plus) return true
|
|
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
return hdr10PlusPattern.test(text)
|
|
})
|
|
|
|
const showHdr10PlusLogo = computed(() => hasHdr10Plus.value && !hasDolbyVision.value)
|
|
|
|
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 (props.inertCard || !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;
|
|
}
|
|
|
|
html.mouse-active .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);
|
|
}
|
|
|
|
html.mouse-active .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-inert {
|
|
cursor: default;
|
|
}
|
|
|
|
.version-row.version-inert .version-main,
|
|
.version-row.version-inert .version-dolby-cell {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.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: center;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
height: 36px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.version-dolby {
|
|
align-self: stretch;
|
|
height: 100%;
|
|
}
|
|
|
|
.version-disc-logo {
|
|
align-self: stretch;
|
|
display: block;
|
|
width: auto;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
filter: drop-shadow(0 0 0.4px rgba(0, 0, 0, 0.5));
|
|
}
|
|
|
|
.version-badges {
|
|
--badge-row-height: 1.2rem;
|
|
/* Regular hex geometry: horizontal inset = h / (2 * sqrt(3)) ~= 0.288675 * h */
|
|
--badge-hex-inset: calc(var(--badge-row-height) * 0.288675);
|
|
--badge-slant: 0.25em;
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
align-items: stretch;
|
|
gap: 0;
|
|
height: var(--badge-row-height);
|
|
}
|
|
|
|
.v-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 100%;
|
|
font-size: 0.78rem;
|
|
line-height: 1;
|
|
padding: 0 0.5em 0 0.3em;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
margin-left: calc(-1 * var(--badge-slant));
|
|
clip-path: polygon(var(--badge-slant) 0, 100% 0, 100% 100%, 0 100%);
|
|
}
|
|
|
|
.v-badge:first-child {
|
|
margin-left: 0;
|
|
padding-left: 0.5em;
|
|
clip-path: polygon(
|
|
var(--badge-hex-inset) 0,
|
|
100% 0,
|
|
100% 100%,
|
|
var(--badge-hex-inset) 100%,
|
|
0 50%
|
|
);
|
|
}
|
|
|
|
.v-badge:last-child {
|
|
padding-right: 0.5em;
|
|
clip-path: polygon(
|
|
var(--badge-slant) 0,
|
|
calc(100% - var(--badge-hex-inset)) 0,
|
|
100% 50%,
|
|
calc(100% - var(--badge-hex-inset)) 100%,
|
|
0 100%
|
|
);
|
|
}
|
|
|
|
.v-badge:only-child {
|
|
margin-left: 0;
|
|
padding-left: 6px;
|
|
padding-right: calc(6px + var(--badge-hex-inset));
|
|
clip-path: polygon(
|
|
var(--badge-hex-inset) 0,
|
|
calc(100% - var(--badge-hex-inset)) 0,
|
|
100% 50%,
|
|
calc(100% - var(--badge-hex-inset)) 100%,
|
|
var(--badge-hex-inset) 100%,
|
|
0 50%
|
|
);
|
|
}
|
|
|
|
.version-service-logo {
|
|
align-self: stretch;
|
|
display: block;
|
|
width: auto;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.version-hdr10plus-logo {
|
|
align-self: stretch;
|
|
display: block;
|
|
width: auto;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.v-badge.res {
|
|
background: #111111;
|
|
color: #f8fafc;
|
|
}
|
|
|
|
.v-badge.qual {
|
|
background: #334155;
|
|
color: #f1f5f9;
|
|
}
|
|
|
|
.v-badge.codec {
|
|
background: #1f2937;
|
|
color: #f3f4f6;
|
|
}
|
|
|
|
.v-badge.audio {
|
|
background: #475569;
|
|
color: #f1f5f9;
|
|
}
|
|
|
|
.v-badge.hdr {
|
|
background: #d4a017;
|
|
color: #1c1917;
|
|
}
|
|
|
|
.v-badge.group {
|
|
background: #1c54a1;
|
|
color: #f8fafc;
|
|
}
|
|
|
|
.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: none;
|
|
background: transparent;
|
|
color: rgba(255, 255, 255, 0.65);
|
|
border-radius: 6px;
|
|
padding: 4px 8px;
|
|
font-size: 2em;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
transition: color 0.15s ease;
|
|
}
|
|
|
|
html.mouse-active .ctx-btn:hover:not(:disabled),
|
|
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
|
|
.ctx-btn:focus-visible:not(:disabled) {
|
|
color: #fff;
|
|
outline: none;
|
|
}
|
|
|
|
.ctx-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.ctx-btn-folder {
|
|
width: auto;
|
|
text-align: center;
|
|
padding: 4px 8px;
|
|
}
|
|
</style>
|