feat(ui): redesign release card presentation
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div v-if="showDolbyLogo" class="dolby-badges" :class="{ compact }">
|
||||
<img
|
||||
:src="dolbyLogoSrc"
|
||||
:alt="dolbyLogoAlt"
|
||||
class="dolby-logo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dolbyAtmosUrl from '../assets/dolby-atmos.webp';
|
||||
import dolbyVisionUrl from '../assets/dolby-vision.webp';
|
||||
import dolbyVisionAtmosUrl from '../assets/dolby-vision-atmos.webp';
|
||||
|
||||
const props = defineProps<{
|
||||
hasDolbyVision?: boolean | null;
|
||||
hasDolbyAtmos?: boolean | null;
|
||||
isHdr?: boolean | null;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const hasDolbyVision = computed(() => props.hasDolbyVision === true);
|
||||
const hasDolbyAtmos = computed(() => props.hasDolbyAtmos === true);
|
||||
const compact = computed(() => props.compact === true);
|
||||
|
||||
const showDolbyLogo = computed(() => hasDolbyVision.value || hasDolbyAtmos.value);
|
||||
|
||||
const dolbyLogoSrc = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return dolbyVisionAtmosUrl;
|
||||
if (hasDolbyVision.value) return dolbyVisionUrl;
|
||||
return dolbyAtmosUrl;
|
||||
});
|
||||
|
||||
const dolbyLogoAlt = computed(() => {
|
||||
if (hasDolbyVision.value && hasDolbyAtmos.value) return 'Dolby Vision + Dolby Atmos';
|
||||
if (hasDolbyVision.value) return 'Dolby Vision';
|
||||
return 'Dolby Atmos';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dolby-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.dolby-logo {
|
||||
height: 100%;
|
||||
max-height: 44px;
|
||||
min-height: 30px;
|
||||
width: auto;
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
filter: invert(1) brightness(1.1) contrast(1.05);
|
||||
}
|
||||
|
||||
.dolby-badges.compact .dolby-logo {
|
||||
max-height: 34px;
|
||||
min-height: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div v-if="hasContent" class="language-flags" :class="{ compact }">
|
||||
<span v-if="label" class="language-flags-label">{{ label }}</span>
|
||||
<span class="language-flag-list">
|
||||
<span
|
||||
v-for="entry in flagEntries"
|
||||
:key="entry.countryCode"
|
||||
class="language-flag"
|
||||
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
|
||||
v-html="entry.svg"
|
||||
></span>
|
||||
<span
|
||||
v-for="code in unmappedCodes"
|
||||
:key="`raw-${code}`"
|
||||
class="language-code-fallback"
|
||||
:title="code"
|
||||
>{{ code }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string;
|
||||
codes: string[] | null | undefined;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const mapped = computed(() => buildLanguageFlags(props.codes));
|
||||
const flagEntries = computed(() => mapped.value.flags);
|
||||
const unmappedCodes = computed(() => mapped.value.unmappedCodes);
|
||||
const hasContent = computed(() => flagEntries.value.length > 0 || unmappedCodes.value.length > 0);
|
||||
const compact = computed(() => props.compact === true);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-flags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.language-flags-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.language-flag-list {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35) inset;
|
||||
}
|
||||
|
||||
.language-flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.language-code-fallback {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 3px;
|
||||
padding: 0.08rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-flags-label {
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-flag {
|
||||
width: 16px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.language-flags.compact .language-code-fallback {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
</style>
|
||||
@@ -91,29 +91,43 @@
|
||||
v-for="(version, index) in movieVersions"
|
||||
:key="index"
|
||||
class="version-row"
|
||||
:class="{ 'version-best': index === 0 }"
|
||||
:class="{
|
||||
'version-best': index === 0,
|
||||
'version-selectable': !!version.playable_file,
|
||||
'version-disabled': !version.playable_file,
|
||||
}"
|
||||
tabindex="0"
|
||||
v-bind="navAttrs(2, index)"
|
||||
@click="handleVersionActivate(version, $event)"
|
||||
@keydown.enter.prevent="handleVersionActivate(version, $event)"
|
||||
@keydown.space.prevent="handleVersionActivate(version, $event)"
|
||||
:title="version.playable_file ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file'"
|
||||
>
|
||||
<div class="version-info">
|
||||
<div class="version-main">
|
||||
<div class="version-badges">
|
||||
<span v-if="version.resolution" class="v-badge res">{{ version.resolution }}</span>
|
||||
<span v-if="version.quality" class="v-badge qual">{{ version.quality }}</span>
|
||||
<span v-if="version.codec" class="v-badge codec">{{ version.codec }}</span>
|
||||
<span v-if="version.audio" class="v-badge audio">{{ version.audio }}</span>
|
||||
<span v-if="getDisplayQualityBadge(version)" class="v-badge qual">{{ getDisplayQualityBadge(version) }}</span>
|
||||
<span v-if="getDisplayCodecBadge(version)" class="v-badge codec">{{ getDisplayCodecBadge(version) }}</span>
|
||||
<span v-if="getShowHdrBadge(version)" class="v-badge hdr">HDR</span>
|
||||
<span v-if="getDisplayAudioBadge(version)" class="v-badge audio">{{ getDisplayAudioBadge(version) }}</span>
|
||||
<span v-if="isVersionDisc(version)" class="v-disc">💿</span>
|
||||
</div>
|
||||
<div class="version-language-flags">
|
||||
<LanguageFlags class="language-flags-audio" :codes="version.audio_languages" />
|
||||
<span
|
||||
v-if="hasLanguageDisplay(version.audio_languages) && hasLanguageDisplay(version.subtitle_languages)"
|
||||
class="language-separator"
|
||||
>•</span>
|
||||
<LanguageFlags class="language-flags-subs" :codes="version.subtitle_languages" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button
|
||||
class="btn btn-small btn-primary"
|
||||
v-bind="navAttrs(2, index * 2)"
|
||||
@click="handlePlay(version.playable_file)"
|
||||
:disabled="!version.playable_file"
|
||||
>▶ {{ getPlayLabel(version.playable_file) }}</button>
|
||||
<button
|
||||
class="btn btn-small btn-secondary"
|
||||
v-bind="navAttrs(2, index * 2 + 1)"
|
||||
@click="handleOpenFolder(version.playable_file || '')"
|
||||
>📁</button>
|
||||
<div class="version-dolby-cell">
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="getHasDolbyVision(version)"
|
||||
:has-dolby-atmos="getHasDolbyAtmos(version)"
|
||||
:is-hdr="getHasHdr(version)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +192,10 @@ import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrow
|
||||
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
||||
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
||||
import SeriesFullView from './SeriesFullView.vue';
|
||||
import LanguageFlags from './LanguageFlags.vue';
|
||||
import DolbyBadges from './DolbyBadges.vue';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
@@ -385,6 +402,74 @@ const synopsisPosterUrl = computed(() => {
|
||||
return getCoverUrl(props.item.cover_path);
|
||||
});
|
||||
|
||||
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 getHasDolbyVision(version: Torrent): boolean {
|
||||
return (
|
||||
version.has_dolby_vision === true
|
||||
|| hasAnyTag(dolbyVisionPattern, version.quality, version.codec, version.audio, version.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getHasDolbyAtmos(version: Torrent): boolean {
|
||||
return (
|
||||
version.has_dolby_atmos === true
|
||||
|| hasAnyTag(dolbyAtmosPattern, version.quality, version.codec, version.audio, version.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getHasHdr(version: Torrent): boolean {
|
||||
return (
|
||||
version.is_hdr === true
|
||||
|| hasAnyTag(hdrPattern, version.quality, version.codec, version.audio, version.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayQualityBadge(version: Torrent): string | null {
|
||||
if (!version.quality || hasDolbyTag(version.quality)) return null;
|
||||
if (blurayTagPattern.test(version.quality) && !isVersionDisc(version)) return null;
|
||||
return version.quality;
|
||||
}
|
||||
|
||||
function getDisplayCodecBadge(version: Torrent): string | null {
|
||||
if (!version.codec || hasDolbyTag(version.codec)) return null;
|
||||
return version.codec;
|
||||
}
|
||||
|
||||
function getDisplayAudioBadge(version: Torrent): string | null {
|
||||
if (!version.audio || hasDolbyTag(version.audio)) return null;
|
||||
return version.audio;
|
||||
}
|
||||
|
||||
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 getShowHdrBadge(version: Torrent): boolean {
|
||||
if (!getHasHdr(version)) return false;
|
||||
return !hasHdrTag(version.quality) && !hasHdrTag(version.codec) && !hasHdrTag(version.audio);
|
||||
}
|
||||
|
||||
// Check if a specific version is a disc format (Blu-ray disc has index.bdmv)
|
||||
function isVersionDisc(version: Torrent): boolean {
|
||||
if (!version.playable_file) return false;
|
||||
@@ -485,8 +570,13 @@ function handlePlay(filePath: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||
if (!version.playable_file) return;
|
||||
if (event.altKey) {
|
||||
handleOpenFolder(version.playable_file);
|
||||
return;
|
||||
}
|
||||
handlePlay(version.playable_file);
|
||||
}
|
||||
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
@@ -938,6 +1028,75 @@ function handleOpenFolder(folderPath: string) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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% - 18px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 18px), 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.98rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-meta-grid .version-badges {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.version-meta-grid .version-language-flags {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.version-dolby {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: stretch;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
@@ -983,7 +1142,7 @@ function handleOpenFolder(folderPath: string) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Season header styling */
|
||||
@@ -1402,12 +1561,13 @@ function handleOpenFolder(folderPath: string) {
|
||||
}
|
||||
|
||||
.version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: stretch;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: rgba(10, 14, 22, 0.2);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 8px;
|
||||
@@ -1416,28 +1576,53 @@ function handleOpenFolder(folderPath: string) {
|
||||
}
|
||||
|
||||
.version-row:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(10, 14, 22, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.version-row.version-best {
|
||||
border-color: rgba(70, 211, 105, 0.5);
|
||||
background: rgba(70, 211, 105, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(10, 14, 22, 0.2);
|
||||
}
|
||||
|
||||
.version-row.version-best:hover {
|
||||
background: rgba(70, 211, 105, 0.25);
|
||||
background: rgba(10, 14, 22, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
flex: 1;
|
||||
.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-actions {
|
||||
.version-dolby-cell {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-dolby {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@@ -1489,17 +1674,9 @@ function handleOpenFolder(folderPath: string) {
|
||||
color: #fffbeb;
|
||||
}
|
||||
|
||||
.version-sub {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
.v-badge.hdr {
|
||||
background: #166534;
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
/* Collage images for header */
|
||||
.collage-images {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -138,7 +138,25 @@
|
||||
:key="index"
|
||||
class="context-menu-version"
|
||||
>
|
||||
<div class="version-label">{{ getVersionLabel(torrent) }}</div>
|
||||
<div class="version-main">
|
||||
<div class="version-label">{{ getVersionLabel(torrent) }}</div>
|
||||
<div class="version-language-flags">
|
||||
<LanguageFlags class="language-flags-audio" :codes="torrent.audio_languages" compact />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-dolby-cell">
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="getHasDolbyVision(torrent)"
|
||||
:has-dolby-atmos="getHasDolbyAtmos(torrent)"
|
||||
:is-hdr="getHasHdr(torrent)"
|
||||
/>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<button
|
||||
class="ctx-btn ctx-btn-play"
|
||||
@@ -167,6 +185,9 @@ import { computed, ref, nextTick, watch } from 'vue';
|
||||
import type { Series, Season, Episode, Torrent } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import LanguageFlags from './LanguageFlags.vue';
|
||||
import DolbyBadges from './DolbyBadges.vue';
|
||||
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||
|
||||
const props = defineProps<{
|
||||
series: Series;
|
||||
@@ -320,13 +341,59 @@ function handleOpenFolder(folderPath: string) {
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 getHasDolbyVision(torrent: Torrent): boolean {
|
||||
return (
|
||||
torrent.has_dolby_vision === true
|
||||
|| hasAnyTag(dolbyVisionPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getHasDolbyAtmos(torrent: Torrent): boolean {
|
||||
return (
|
||||
torrent.has_dolby_atmos === true
|
||||
|| hasAnyTag(dolbyAtmosPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getHasHdr(torrent: Torrent): boolean {
|
||||
return (
|
||||
torrent.is_hdr === true
|
||||
|| hasAnyTag(hdrPattern, torrent.quality, torrent.codec, torrent.audio, torrent.title)
|
||||
);
|
||||
}
|
||||
|
||||
function hasLanguageDisplay(codes: string[] | null | undefined): boolean {
|
||||
const mapped = buildLanguageFlags(codes);
|
||||
return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0;
|
||||
}
|
||||
|
||||
// Get version display label
|
||||
function getVersionLabel(torrent: Torrent): string {
|
||||
const parts: string[] = [];
|
||||
if (torrent.resolution) parts.push(torrent.resolution);
|
||||
if (torrent.quality) parts.push(torrent.quality);
|
||||
if (torrent.codec) parts.push(torrent.codec);
|
||||
if (torrent.audio) parts.push(torrent.audio);
|
||||
if (torrent.quality && !hasDolbyTag(torrent.quality)) parts.push(torrent.quality);
|
||||
if (torrent.codec && !hasDolbyTag(torrent.codec)) parts.push(torrent.codec);
|
||||
if (torrent.audio && !hasDolbyTag(torrent.audio)) parts.push(torrent.audio);
|
||||
const hasExistingHdr = [torrent.quality, torrent.codec, torrent.audio].some(
|
||||
(value) => Boolean(value && hdrPattern.test(value))
|
||||
);
|
||||
if (getHasHdr(torrent) && !hasExistingHdr) parts.push('HDR');
|
||||
return parts.length > 0 ? parts.join(' • ') : 'Unknown';
|
||||
}
|
||||
|
||||
@@ -931,11 +998,12 @@ function handlePlay(episode: Episode) {
|
||||
}
|
||||
|
||||
.context-menu-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content max-content;
|
||||
align-items: stretch;
|
||||
padding: 10px 16px;
|
||||
gap: 12px;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
@@ -947,15 +1015,79 @@ function handlePlay(episode: Episode) {
|
||||
.version-label {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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-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: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ export interface Torrent {
|
||||
audio: string | null;
|
||||
audio_languages: string[] | null;
|
||||
subtitle_languages: string[] | null;
|
||||
is_hdr: boolean;
|
||||
has_dolby_vision: boolean;
|
||||
has_dolby_atmos: boolean;
|
||||
encoder: string | null;
|
||||
size: number | null;
|
||||
added_at: number | null;
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import * as flagSvgs from 'country-flag-icons/string/3x2';
|
||||
|
||||
export interface LanguageFlagEntry {
|
||||
countryCode: string;
|
||||
svg: string;
|
||||
sourceCodes: string[];
|
||||
}
|
||||
|
||||
const FLAGS = flagSvgs as Record<string, string>;
|
||||
|
||||
const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
||||
// English
|
||||
en: 'GB',
|
||||
eng: 'GB',
|
||||
|
||||
// Spanish (including LATAM variants collapsed to Spain flag)
|
||||
es: 'ES',
|
||||
spa: 'ES',
|
||||
esl: 'ES',
|
||||
spl: 'ES',
|
||||
'es-es': 'ES',
|
||||
'es-419': 'ES',
|
||||
'spa-la': 'ES',
|
||||
|
||||
// Portuguese
|
||||
pt: 'PT',
|
||||
por: 'PT',
|
||||
'pt-pt': 'PT',
|
||||
'pt-br': 'BR',
|
||||
|
||||
// Major European languages
|
||||
fr: 'FR',
|
||||
fra: 'FR',
|
||||
fre: 'FR',
|
||||
de: 'DE',
|
||||
deu: 'DE',
|
||||
ger: 'DE',
|
||||
it: 'IT',
|
||||
ita: 'IT',
|
||||
nl: 'NL',
|
||||
nld: 'NL',
|
||||
dut: 'NL',
|
||||
sv: 'SE',
|
||||
swe: 'SE',
|
||||
no: 'NO',
|
||||
nor: 'NO',
|
||||
da: 'DK',
|
||||
dan: 'DK',
|
||||
fi: 'FI',
|
||||
fin: 'FI',
|
||||
pl: 'PL',
|
||||
पोल: 'PL',
|
||||
cs: 'CZ',
|
||||
ces: 'CZ',
|
||||
cze: 'CZ',
|
||||
hu: 'HU',
|
||||
hun: 'HU',
|
||||
ro: 'RO',
|
||||
ron: 'RO',
|
||||
rum: 'RO',
|
||||
el: 'GR',
|
||||
gre: 'GR',
|
||||
ell: 'GR',
|
||||
tr: 'TR',
|
||||
tur: 'TR',
|
||||
|
||||
// Slavic / Eurasian
|
||||
ru: 'RU',
|
||||
rus: 'RU',
|
||||
uk: 'UA',
|
||||
ukr: 'UA',
|
||||
bg: 'BG',
|
||||
bul: 'BG',
|
||||
sr: 'RS',
|
||||
srp: 'RS',
|
||||
hr: 'HR',
|
||||
hrv: 'HR',
|
||||
sl: 'SI',
|
||||
slv: 'SI',
|
||||
sk: 'SK',
|
||||
slk: 'SK',
|
||||
slo: 'SK',
|
||||
|
||||
// East / South / SE Asia
|
||||
ja: 'JP',
|
||||
jpn: 'JP',
|
||||
ko: 'KR',
|
||||
kor: 'KR',
|
||||
zh: 'CN',
|
||||
zho: 'CN',
|
||||
chi: 'CN',
|
||||
yue: 'HK',
|
||||
th: 'TH',
|
||||
tha: 'TH',
|
||||
vi: 'VN',
|
||||
vie: 'VN',
|
||||
id: 'ID',
|
||||
ind: 'ID',
|
||||
ms: 'MY',
|
||||
msa: 'MY',
|
||||
may: 'MY',
|
||||
hi: 'IN',
|
||||
hin: 'IN',
|
||||
|
||||
// Middle East / Africa
|
||||
ar: 'SA',
|
||||
ara: 'SA',
|
||||
he: 'IL',
|
||||
heb: 'IL',
|
||||
fa: 'IR',
|
||||
fas: 'IR',
|
||||
per: 'IR',
|
||||
ur: 'PK',
|
||||
urd: 'PK',
|
||||
sw: 'TZ',
|
||||
swa: 'TZ',
|
||||
|
||||
// Other common
|
||||
ca: 'ES',
|
||||
cat: 'ES',
|
||||
eu: 'ES',
|
||||
baq: 'ES',
|
||||
eus: 'ES',
|
||||
};
|
||||
|
||||
function normalizeLanguageCode(code: string): string {
|
||||
return code.trim().toLowerCase().replace('_', '-');
|
||||
}
|
||||
|
||||
function normalizeLanguageName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
let _browserLanguagePreferences: string[] | null = null;
|
||||
let _browserPreferenceRanks: Map<string, number> | null = null;
|
||||
|
||||
const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
|
||||
english: 'en',
|
||||
spanish: 'es',
|
||||
portuguese: 'pt',
|
||||
french: 'fr',
|
||||
german: 'de',
|
||||
italian: 'it',
|
||||
dutch: 'nl',
|
||||
swedish: 'sv',
|
||||
norwegian: 'no',
|
||||
danish: 'da',
|
||||
finnish: 'fi',
|
||||
polish: 'pl',
|
||||
czech: 'cs',
|
||||
hungarian: 'hu',
|
||||
romanian: 'ro',
|
||||
greek: 'el',
|
||||
turkish: 'tr',
|
||||
russian: 'ru',
|
||||
ukrainian: 'uk',
|
||||
bulgarian: 'bg',
|
||||
serbian: 'sr',
|
||||
croatian: 'hr',
|
||||
slovenian: 'sl',
|
||||
slovak: 'sk',
|
||||
japanese: 'ja',
|
||||
korean: 'ko',
|
||||
chinese: 'zh',
|
||||
cantonese: 'yue',
|
||||
thai: 'th',
|
||||
vietnamese: 'vi',
|
||||
indonesian: 'id',
|
||||
malay: 'ms',
|
||||
hindi: 'hi',
|
||||
arabic: 'ar',
|
||||
hebrew: 'he',
|
||||
persian: 'fa',
|
||||
urdu: 'ur',
|
||||
swahili: 'sw',
|
||||
catalan: 'ca',
|
||||
basque: 'eu',
|
||||
};
|
||||
|
||||
function getBrowserLanguagePreferences(): string[] {
|
||||
if (_browserLanguagePreferences) return _browserLanguagePreferences;
|
||||
|
||||
const preferences: string[] = [];
|
||||
if (typeof navigator !== 'undefined') {
|
||||
if (Array.isArray(navigator.languages)) {
|
||||
for (const lang of navigator.languages) {
|
||||
if (typeof lang === 'string' && lang.trim()) {
|
||||
preferences.push(normalizeLanguageCode(lang));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof navigator.language === 'string' && navigator.language.trim()) {
|
||||
preferences.push(normalizeLanguageCode(navigator.language));
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(preferences));
|
||||
_browserLanguagePreferences = deduped;
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function getBrowserPreferenceRanks(): Map<string, number> {
|
||||
if (_browserPreferenceRanks) return _browserPreferenceRanks;
|
||||
|
||||
const preferences = getBrowserLanguagePreferences();
|
||||
const ranks = new Map<string, number>();
|
||||
const display = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
|
||||
for (const [index, pref] of preferences.entries()) {
|
||||
const base = pref.split('-', 1)[0];
|
||||
if (!ranks.has(pref)) ranks.set(pref, index);
|
||||
if (!ranks.has(base)) ranks.set(base, index);
|
||||
|
||||
const prefName = display.of(pref);
|
||||
if (prefName) {
|
||||
const key = normalizeLanguageName(prefName);
|
||||
if (!ranks.has(key)) ranks.set(key, index);
|
||||
}
|
||||
const baseName = display.of(base);
|
||||
if (baseName) {
|
||||
const key = normalizeLanguageName(baseName);
|
||||
if (!ranks.has(key)) ranks.set(key, index);
|
||||
}
|
||||
}
|
||||
|
||||
_browserPreferenceRanks = ranks;
|
||||
return ranks;
|
||||
}
|
||||
|
||||
function resolveLanguageIdentifier(value: string): string {
|
||||
const normalized = normalizeLanguageCode(value);
|
||||
if (/^[a-z]{2,3}(?:-[a-z0-9]{2,})?$/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const nameKey = normalizeLanguageName(value);
|
||||
const byName = LANGUAGE_NAME_TO_CODE[nameKey];
|
||||
if (byName) return byName;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getPreferenceRank(value: string, preferenceRanks: Map<string, number>): number {
|
||||
const normalized = resolveLanguageIdentifier(value);
|
||||
const exact = preferenceRanks.get(normalized);
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
const base = normalized.split('-', 1)[0];
|
||||
const baseRank = preferenceRanks.get(base);
|
||||
if (baseRank !== undefined) return baseRank;
|
||||
|
||||
const nameKey = normalizeLanguageName(toLanguageName(normalized));
|
||||
const nameRank = preferenceRanks.get(nameKey);
|
||||
if (nameRank !== undefined) return nameRank;
|
||||
|
||||
const rawNameRank = preferenceRanks.get(normalizeLanguageName(value));
|
||||
if (rawNameRank !== undefined) return rawNameRank;
|
||||
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export function mapLanguageToCountry(code: string): string | null {
|
||||
const normalized = resolveLanguageIdentifier(code);
|
||||
|
||||
const direct = LANGUAGE_TO_COUNTRY[normalized];
|
||||
if (direct) return direct;
|
||||
|
||||
// region-tag style code like en-us / pt-br / es-mx
|
||||
const hyphenParts = normalized.split('-');
|
||||
if (hyphenParts.length >= 2) {
|
||||
const region = hyphenParts[hyphenParts.length - 1];
|
||||
if (/^[a-z]{2}$/i.test(region)) {
|
||||
return region.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildLanguageFlags(codes: string[] | null | undefined): {
|
||||
flags: LanguageFlagEntry[];
|
||||
unmappedCodes: string[];
|
||||
} {
|
||||
if (!codes || codes.length === 0) {
|
||||
return { flags: [], unmappedCodes: [] };
|
||||
}
|
||||
|
||||
const byCountry = new Map<string, string[]>();
|
||||
const unmapped: string[] = [];
|
||||
const seenUnmapped = new Set<string>();
|
||||
const preferenceRanks = getBrowserPreferenceRanks();
|
||||
|
||||
const ordered = codes
|
||||
.map((raw, index) => ({ raw, index }))
|
||||
.filter((v) => Boolean(v.raw && String(v.raw).trim()))
|
||||
.sort((a, b) => {
|
||||
const aRank = getPreferenceRank(String(a.raw), preferenceRanks);
|
||||
const bRank = getPreferenceRank(String(b.raw), preferenceRanks);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
for (const { raw } of ordered) {
|
||||
if (!raw) continue;
|
||||
const countryCode = mapLanguageToCountry(raw);
|
||||
if (!countryCode) {
|
||||
const upper = raw.toUpperCase();
|
||||
if (!seenUnmapped.has(upper)) {
|
||||
seenUnmapped.add(upper);
|
||||
unmapped.push(upper);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!FLAGS[countryCode]) {
|
||||
const upper = raw.toUpperCase();
|
||||
if (!seenUnmapped.has(upper)) {
|
||||
seenUnmapped.add(upper);
|
||||
unmapped.push(upper);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const existing = byCountry.get(countryCode) || [];
|
||||
existing.push(raw);
|
||||
byCountry.set(countryCode, existing);
|
||||
}
|
||||
|
||||
const flags: LanguageFlagEntry[] = Array.from(byCountry.entries()).map(
|
||||
([countryCode, sourceCodes]) => ({
|
||||
countryCode,
|
||||
svg: FLAGS[countryCode],
|
||||
sourceCodes,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
flags,
|
||||
unmappedCodes: unmapped,
|
||||
};
|
||||
}
|
||||
|
||||
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
||||
eng: 'English',
|
||||
spa: 'Spanish',
|
||||
'spa-la': 'Spanish',
|
||||
esl: 'Spanish',
|
||||
spl: 'Spanish',
|
||||
por: 'Portuguese',
|
||||
fre: 'French',
|
||||
fra: 'French',
|
||||
ger: 'German',
|
||||
deu: 'German',
|
||||
ita: 'Italian',
|
||||
nld: 'Dutch',
|
||||
dut: 'Dutch',
|
||||
swe: 'Swedish',
|
||||
nor: 'Norwegian',
|
||||
dan: 'Danish',
|
||||
fin: 'Finnish',
|
||||
pol: 'Polish',
|
||||
ces: 'Czech',
|
||||
cze: 'Czech',
|
||||
hun: 'Hungarian',
|
||||
ron: 'Romanian',
|
||||
rum: 'Romanian',
|
||||
ell: 'Greek',
|
||||
gre: 'Greek',
|
||||
tur: 'Turkish',
|
||||
rus: 'Russian',
|
||||
ukr: 'Ukrainian',
|
||||
bul: 'Bulgarian',
|
||||
srp: 'Serbian',
|
||||
hrv: 'Croatian',
|
||||
slv: 'Slovenian',
|
||||
slk: 'Slovak',
|
||||
slo: 'Slovak',
|
||||
jpn: 'Japanese',
|
||||
kor: 'Korean',
|
||||
zho: 'Chinese',
|
||||
chi: 'Chinese',
|
||||
yue: 'Cantonese',
|
||||
tha: 'Thai',
|
||||
vie: 'Vietnamese',
|
||||
ind: 'Indonesian',
|
||||
msa: 'Malay',
|
||||
may: 'Malay',
|
||||
hin: 'Hindi',
|
||||
ara: 'Arabic',
|
||||
heb: 'Hebrew',
|
||||
fas: 'Persian',
|
||||
per: 'Persian',
|
||||
urd: 'Urdu',
|
||||
swa: 'Swahili',
|
||||
cat: 'Catalan',
|
||||
eus: 'Basque',
|
||||
baq: 'Basque',
|
||||
};
|
||||
|
||||
function toLanguageName(code: string): string {
|
||||
const normalized = normalizeLanguageCode(code);
|
||||
const override = LANGUAGE_NAME_OVERRIDES[normalized];
|
||||
if (override) return override;
|
||||
|
||||
const display = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
const candidate = display.of(normalized);
|
||||
if (candidate) return candidate;
|
||||
|
||||
const base = normalized.split('-', 1)[0];
|
||||
const baseOverride = LANGUAGE_NAME_OVERRIDES[base];
|
||||
if (baseOverride) return baseOverride;
|
||||
const baseCandidate = display.of(base);
|
||||
if (baseCandidate) return baseCandidate;
|
||||
|
||||
return code.toUpperCase();
|
||||
}
|
||||
|
||||
function summarizeLanguageCodes(codes: string[] | null | undefined): string {
|
||||
if (!codes || codes.length === 0) return '';
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const raw of codes) {
|
||||
if (!raw) continue;
|
||||
const name = toLanguageName(raw).trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
export function formatAudioSubtitleSummary(
|
||||
audioCodes: string[] | null | undefined,
|
||||
subtitleCodes: string[] | null | undefined
|
||||
): string {
|
||||
const audio = summarizeLanguageCodes(audioCodes);
|
||||
const subs = summarizeLanguageCodes(subtitleCodes);
|
||||
if (audio && subs) return `${audio} / ${subs}`;
|
||||
return audio || subs;
|
||||
}
|
||||
Reference in New Issue
Block a user