feat(ui): redesign release card presentation
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"country-flag-icons": "^1.6.17",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
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"
|
v-for="(version, index) in movieVersions"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="version-row"
|
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">
|
<div class="version-badges">
|
||||||
<span v-if="version.resolution" class="v-badge res">{{ version.resolution }}</span>
|
<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="getDisplayQualityBadge(version)" class="v-badge qual">{{ getDisplayQualityBadge(version) }}</span>
|
||||||
<span v-if="version.codec" class="v-badge codec">{{ version.codec }}</span>
|
<span v-if="getDisplayCodecBadge(version)" class="v-badge codec">{{ getDisplayCodecBadge(version) }}</span>
|
||||||
<span v-if="version.audio" class="v-badge audio">{{ version.audio }}</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>
|
<span v-if="isVersionDisc(version)" class="v-disc">💿</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="version-actions">
|
<div class="version-dolby-cell">
|
||||||
<button
|
<DolbyBadges
|
||||||
class="btn btn-small btn-primary"
|
class="version-dolby"
|
||||||
v-bind="navAttrs(2, index * 2)"
|
:has-dolby-vision="getHasDolbyVision(version)"
|
||||||
@click="handlePlay(version.playable_file)"
|
:has-dolby-atmos="getHasDolbyAtmos(version)"
|
||||||
:disabled="!version.playable_file"
|
:is-hdr="getHasHdr(version)"
|
||||||
>▶ {{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +192,10 @@ import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrow
|
|||||||
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
||||||
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
||||||
import SeriesFullView from './SeriesFullView.vue';
|
import SeriesFullView from './SeriesFullView.vue';
|
||||||
|
import LanguageFlags from './LanguageFlags.vue';
|
||||||
|
import DolbyBadges from './DolbyBadges.vue';
|
||||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
@@ -385,6 +402,74 @@ const synopsisPosterUrl = computed(() => {
|
|||||||
return getCoverUrl(props.item.cover_path);
|
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)
|
// Check if a specific version is a disc format (Blu-ray disc has index.bdmv)
|
||||||
function isVersionDisc(version: Torrent): boolean {
|
function isVersionDisc(version: Torrent): boolean {
|
||||||
if (!version.playable_file) return false;
|
if (!version.playable_file) return false;
|
||||||
@@ -485,8 +570,13 @@ function handlePlay(filePath: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlayLabel(filePath: string | null): string {
|
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
if (!version.playable_file) return;
|
||||||
|
if (event.altKey) {
|
||||||
|
handleOpenFolder(version.playable_file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePlay(version.playable_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenFolder(folderPath: string) {
|
function handleOpenFolder(folderPath: string) {
|
||||||
@@ -938,6 +1028,75 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
gap: 6px;
|
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 {
|
.version-badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -983,7 +1142,7 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 16px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Season header styling */
|
/* Season header styling */
|
||||||
@@ -1402,12 +1561,13 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.version-row {
|
.version-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
gap: 16px;
|
column-gap: 8px;
|
||||||
|
row-gap: 6px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(10, 14, 22, 0.2);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1416,28 +1576,53 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.version-row:hover {
|
.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);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-row.version-best {
|
.version-row.version-best {
|
||||||
border-color: rgba(70, 211, 105, 0.5);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(70, 211, 105, 0.15);
|
background: rgba(10, 14, 22, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-row.version-best:hover {
|
.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 {
|
.version-row.version-selectable {
|
||||||
flex: 1;
|
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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-actions {
|
.version-dolby-cell {
|
||||||
|
grid-column: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: stretch;
|
||||||
flex-shrink: 0;
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-dolby {
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-small {
|
.btn-small {
|
||||||
@@ -1489,17 +1674,9 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
color: #fffbeb;
|
color: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-sub {
|
.v-badge.hdr {
|
||||||
font-size: 0.65rem;
|
background: #166534;
|
||||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
color: #dcfce7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collage images for header */
|
|
||||||
.collage-images {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -138,7 +138,25 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="context-menu-version"
|
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">
|
<div class="version-actions">
|
||||||
<button
|
<button
|
||||||
class="ctx-btn ctx-btn-play"
|
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 type { Series, Season, Episode, Torrent } from '../types';
|
||||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
|
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
|
||||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||||
|
import LanguageFlags from './LanguageFlags.vue';
|
||||||
|
import DolbyBadges from './DolbyBadges.vue';
|
||||||
|
import { buildLanguageFlags } from '../utils/languageFlags';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
series: Series;
|
series: Series;
|
||||||
@@ -320,13 +341,59 @@ function handleOpenFolder(folderPath: string) {
|
|||||||
closeContextMenu();
|
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
|
// Get version display label
|
||||||
function getVersionLabel(torrent: Torrent): string {
|
function getVersionLabel(torrent: Torrent): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (torrent.resolution) parts.push(torrent.resolution);
|
if (torrent.resolution) parts.push(torrent.resolution);
|
||||||
if (torrent.quality) parts.push(torrent.quality);
|
if (torrent.quality && !hasDolbyTag(torrent.quality)) parts.push(torrent.quality);
|
||||||
if (torrent.codec) parts.push(torrent.codec);
|
if (torrent.codec && !hasDolbyTag(torrent.codec)) parts.push(torrent.codec);
|
||||||
if (torrent.audio) parts.push(torrent.audio);
|
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';
|
return parts.length > 0 ? parts.join(' • ') : 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,11 +998,12 @@ function handlePlay(episode: Episode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-version {
|
.context-menu-version {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: minmax(0, 1fr) max-content max-content;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
gap: 12px;
|
column-gap: 8px;
|
||||||
|
row-gap: 6px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -947,15 +1015,79 @@ function handlePlay(episode: Episode) {
|
|||||||
.version-label {
|
.version-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.version-actions {
|
||||||
|
grid-column: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface Torrent {
|
|||||||
audio: string | null;
|
audio: string | null;
|
||||||
audio_languages: string[] | null;
|
audio_languages: string[] | null;
|
||||||
subtitle_languages: string[] | null;
|
subtitle_languages: string[] | null;
|
||||||
|
is_hdr: boolean;
|
||||||
|
has_dolby_vision: boolean;
|
||||||
|
has_dolby_atmos: boolean;
|
||||||
encoder: string | null;
|
encoder: string | null;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
added_at: 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;
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ async def _build_torrent_info(
|
|||||||
audio=item.audio,
|
audio=item.audio,
|
||||||
audio_languages=probe_info.audio_languages if probe_info else None,
|
audio_languages=probe_info.audio_languages if probe_info else None,
|
||||||
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
|
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
|
||||||
|
is_hdr=probe_info.is_hdr if probe_info else False,
|
||||||
|
has_dolby_vision=probe_info.has_dolby_vision if probe_info else False,
|
||||||
|
has_dolby_atmos=probe_info.has_dolby_atmos if probe_info else False,
|
||||||
encoder=item.encoder,
|
encoder=item.encoder,
|
||||||
size=size,
|
size=size,
|
||||||
added_at=added_at,
|
added_at=added_at,
|
||||||
@@ -149,6 +152,9 @@ async def _collect_episode_files(
|
|||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": probe.subtitle_languages,
|
||||||
|
"is_hdr": probe.is_hdr,
|
||||||
|
"has_dolby_vision": probe.has_dolby_vision,
|
||||||
|
"has_dolby_atmos": probe.has_dolby_atmos,
|
||||||
"resolution": item.resolution,
|
"resolution": item.resolution,
|
||||||
"quality": item.quality,
|
"quality": item.quality,
|
||||||
"codec": item.codec,
|
"codec": item.codec,
|
||||||
@@ -193,6 +199,9 @@ async def _collect_episode_files(
|
|||||||
"probed_resolution": probe.resolution,
|
"probed_resolution": probe.resolution,
|
||||||
"audio_languages": probe.audio_languages,
|
"audio_languages": probe.audio_languages,
|
||||||
"subtitle_languages": probe.subtitle_languages,
|
"subtitle_languages": probe.subtitle_languages,
|
||||||
|
"is_hdr": probe.is_hdr,
|
||||||
|
"has_dolby_vision": probe.has_dolby_vision,
|
||||||
|
"has_dolby_atmos": probe.has_dolby_atmos,
|
||||||
"resolution": item.resolution,
|
"resolution": item.resolution,
|
||||||
"quality": item.quality,
|
"quality": item.quality,
|
||||||
"codec": item.codec,
|
"codec": item.codec,
|
||||||
@@ -258,6 +267,9 @@ def _build_episodes_data(
|
|||||||
audio=f.get("audio"),
|
audio=f.get("audio"),
|
||||||
audio_languages=f.get("audio_languages"),
|
audio_languages=f.get("audio_languages"),
|
||||||
subtitle_languages=f.get("subtitle_languages"),
|
subtitle_languages=f.get("subtitle_languages"),
|
||||||
|
is_hdr=bool(f.get("is_hdr")),
|
||||||
|
has_dolby_vision=bool(f.get("has_dolby_vision")),
|
||||||
|
has_dolby_atmos=bool(f.get("has_dolby_atmos")),
|
||||||
encoder=f.get("encoder"),
|
encoder=f.get("encoder"),
|
||||||
size=f.get("size"),
|
size=f.get("size"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -334,6 +334,8 @@ class MediaProbeInfo:
|
|||||||
height: int | None = None
|
height: int | None = None
|
||||||
is_hdr: bool = False
|
is_hdr: bool = False
|
||||||
dovi_profile: int | None = None
|
dovi_profile: int | None = None
|
||||||
|
has_dolby_vision: bool = False
|
||||||
|
has_dolby_atmos: bool = False
|
||||||
resolution: str | None = None
|
resolution: str | None = None
|
||||||
audio_languages: list[str] | None = None
|
audio_languages: list[str] | None = None
|
||||||
subtitle_languages: list[str] | None = None
|
subtitle_languages: list[str] | None = None
|
||||||
@@ -405,12 +407,20 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
|||||||
info.dovi_profile = int(dovi_match.group(1))
|
info.dovi_profile = int(dovi_match.group(1))
|
||||||
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
|
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
|
||||||
info.dovi_profile = 7
|
info.dovi_profile = 7
|
||||||
|
info.has_dolby_vision = info.dovi_profile is not None
|
||||||
|
|
||||||
audio_languages: list[str] = []
|
audio_languages: list[str] = []
|
||||||
for match in _audio_stream_re.finditer(text):
|
for line in text.splitlines():
|
||||||
|
if "Stream #" not in line or "Audio:" not in line:
|
||||||
|
continue
|
||||||
|
match = _audio_stream_re.search(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
lang = _lang_code(match.group(1))
|
lang = _lang_code(match.group(1))
|
||||||
if lang and lang not in audio_languages:
|
if lang and lang not in audio_languages:
|
||||||
audio_languages.append(lang)
|
audio_languages.append(lang)
|
||||||
|
if "atmos" in line.lower():
|
||||||
|
info.has_dolby_atmos = True
|
||||||
info.audio_languages = audio_languages or None
|
info.audio_languages = audio_languages or None
|
||||||
|
|
||||||
subtitle_languages: list[str] = []
|
subtitle_languages: list[str] = []
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class Torrent(msgspec.Struct):
|
|||||||
audio: str | None = None
|
audio: str | None = None
|
||||||
audio_languages: list[str] | None = None
|
audio_languages: list[str] | None = None
|
||||||
subtitle_languages: list[str] | None = None
|
subtitle_languages: list[str] | None = None
|
||||||
|
is_hdr: bool = False
|
||||||
|
has_dolby_vision: bool = False
|
||||||
|
has_dolby_atmos: bool = False
|
||||||
encoder: str | None = None
|
encoder: str | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
added_at: int | None = None
|
added_at: int | None = None
|
||||||
|
|||||||
Reference in New Issue
Block a user