Fix root path handling and release action menus
This commit is contained in:
@@ -177,7 +177,7 @@ async function refreshRoots() {
|
||||
const data = await fetchRoots();
|
||||
roots.value = data.map(r => ({
|
||||
root_id: r.root_id,
|
||||
name: r.path.split('/').pop() || r.path.split('\\').pop() || r.root_id,
|
||||
name: r.name,
|
||||
path: r.path,
|
||||
status: r.status,
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:series="item.data as Series"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
:get-root-name="getRootName"
|
||||
@close="$emit('close')"
|
||||
@play="handlePlay"
|
||||
@openFolder="handleOpenFolder"
|
||||
@@ -96,7 +97,9 @@
|
||||
:disabled="!version.playable_file"
|
||||
v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)"
|
||||
@activate="handleVersionActivate(version, $event)"
|
||||
:title="version.playable_file ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file'"
|
||||
@keydown="handleVersionShortcutKeydown($event, version)"
|
||||
@contextmenu="handleVersionContextMenu($event, version)"
|
||||
:title="version.playable_file ? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.' : 'No playable file'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,28 +155,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="versionActionMenu.visible"
|
||||
class="movie-menu-backdrop"
|
||||
@click="closeVersionActionMenu"
|
||||
@contextmenu.prevent="closeVersionActionMenu"
|
||||
></div>
|
||||
<ReleaseActionMenu
|
||||
:visible="versionActionMenu.visible"
|
||||
:x="versionActionMenu.x"
|
||||
:y="versionActionMenu.y"
|
||||
:file-path="versionActionMenu.filePath"
|
||||
:root-name="versionActionMenu.rootName"
|
||||
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
||||
@play="handlePlayVersion(versionActionMenu.filePath)"
|
||||
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, nextTick } from 'vue';
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
|
||||
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
|
||||
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
|
||||
import SeriesFullView from './SeriesFullView.vue';
|
||||
import ReleaseVersionCard from './ReleaseVersionCard.vue';
|
||||
import ReleaseActionMenu from './ReleaseActionMenu.vue';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
getRootName: (rootId: string | null | undefined) => string | null;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
play: [string];
|
||||
openFolder: [string];
|
||||
openFolder: [string, string | null | undefined];
|
||||
searchActor: [string];
|
||||
}>();
|
||||
|
||||
@@ -456,6 +480,84 @@ const seasons = computed(() => {
|
||||
|
||||
const selectedSeasonIndex = ref<number>(0);
|
||||
|
||||
const versionActionMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
filePath: string | null;
|
||||
rootName: string | null;
|
||||
rootId: string | null;
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
filePath: null,
|
||||
rootName: null,
|
||||
rootId: null,
|
||||
});
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
versionActionMenu.value.visible = false;
|
||||
versionActionMenu.value.filePath = null;
|
||||
versionActionMenu.value.rootName = null;
|
||||
versionActionMenu.value.rootId = null;
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
function handlePlayVersion(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit('play', filePath);
|
||||
}
|
||||
closeVersionActionMenu();
|
||||
}
|
||||
|
||||
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
filePath: version.playable_file || null,
|
||||
rootName: props.getRootName(rootId) || null,
|
||||
rootId,
|
||||
};
|
||||
nextTick(() => {
|
||||
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null;
|
||||
firstAction?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
|
||||
if (!version.playable_file) return;
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
return;
|
||||
}
|
||||
if (key === 'enter' && event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMovieMenuKeydown(event: KeyboardEvent) {
|
||||
if (!versionActionMenu.value.visible) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeVersionActionMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Select first season by default
|
||||
watch(seasons, (s) => {
|
||||
if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
|
||||
@@ -471,15 +573,17 @@ function handlePlay(filePath: string | null) {
|
||||
|
||||
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||
if (!version.playable_file) return;
|
||||
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
|
||||
if (event.altKey) {
|
||||
handleOpenFolder(version.playable_file);
|
||||
handleOpenFolder(version.playable_file, rootId);
|
||||
return;
|
||||
}
|
||||
handlePlay(version.playable_file);
|
||||
}
|
||||
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
emit('openFolder', folderPath);
|
||||
function handleOpenFolder(folderPath: string, rootId?: string | null) {
|
||||
closeVersionActionMenu();
|
||||
emit('openFolder', folderPath, rootId);
|
||||
}
|
||||
|
||||
function handleCastSelect(castName: string) {
|
||||
@@ -487,6 +591,14 @@ function handleCastSelect(castName: string) {
|
||||
if (!name) return;
|
||||
emit('searchActor', name);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleMovieMenuKeydown, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleMovieMenuKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -495,6 +607,12 @@ function handleCastSelect(castName: string) {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.movie-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.movie-page-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="version-action-menu"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<div class="version-action-path" :title="resolvedPath">
|
||||
{{ resolvedPath }}
|
||||
</div>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="disabled"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" focusable="false">
|
||||
<path d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ playLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="disabled"
|
||||
@click="emit('openFolder')"
|
||||
>
|
||||
<span class="version-action-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" focusable="false">
|
||||
<path d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z" />
|
||||
</svg>
|
||||
</span>
|
||||
Open Folder
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
filePath: string | null;
|
||||
rootName?: string | null;
|
||||
playLabel?: string;
|
||||
}>(), {
|
||||
rootName: null,
|
||||
playLabel: 'Play',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [];
|
||||
openFolder: [];
|
||||
}>();
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const menuLeft = ref(0);
|
||||
const menuTop = ref(0);
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
|
||||
function toPosixPath(value: string | null | undefined): string {
|
||||
return (value || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
const resolvedPath = computed(() => {
|
||||
if (!props.filePath) return 'No playable file';
|
||||
const normalizedFilePath = toPosixPath(props.filePath);
|
||||
const rootName = toPosixPath((props.rootName || '').trim());
|
||||
if (!rootName) return normalizedFilePath;
|
||||
return `${rootName}/${normalizedFilePath}`;
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}));
|
||||
|
||||
const disabled = computed(() => !props.filePath);
|
||||
|
||||
function clampToViewport() {
|
||||
const menu = menuRef.value;
|
||||
if (!menu) return;
|
||||
|
||||
const width = menu.offsetWidth;
|
||||
const height = menu.offsetHeight;
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN);
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN);
|
||||
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft);
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop);
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!props.visible) return;
|
||||
clampToViewport();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.x, props.y, resolvedPath.value],
|
||||
async ([visible]) => {
|
||||
if (!visible) return;
|
||||
await nextTick();
|
||||
clampToViewport();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-action-menu {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
min-width: 260px;
|
||||
max-width: min(680px, calc(100vw - 24px));
|
||||
background: rgba(18, 20, 28, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-action-path {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.version-action-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html.mouse-active .version-action-item:hover:not(:disabled),
|
||||
.version-action-item:focus-visible:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.version-action-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-action-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
|
||||
.version-action-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -150,26 +150,16 @@
|
||||
No versions available
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="versionActionMenu.visible && versionActionMenu.torrent"
|
||||
class="version-action-menu"
|
||||
:style="{ left: versionActionMenu.x + 'px', top: versionActionMenu.y + 'px' }"
|
||||
>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="!versionActionMenu.torrent.playable_file"
|
||||
@click="handlePlayVersion(versionActionMenu.torrent.playable_file)"
|
||||
>
|
||||
{{ getPlayLabel(versionActionMenu.torrent.playable_file) }}
|
||||
</button>
|
||||
<button
|
||||
class="version-action-item"
|
||||
:disabled="!versionActionMenu.torrent.playable_file"
|
||||
@click="handleOpenFolder(versionActionMenu.torrent.playable_file || '')"
|
||||
>
|
||||
Open Folder
|
||||
</button>
|
||||
</div>
|
||||
<ReleaseActionMenu
|
||||
:visible="versionActionMenu.visible"
|
||||
:x="versionActionMenu.x"
|
||||
:y="versionActionMenu.y"
|
||||
:file-path="versionActionMenu.filePath"
|
||||
:root-name="versionActionMenu.rootName"
|
||||
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
||||
@play="handlePlayVersion(versionActionMenu.filePath)"
|
||||
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -180,17 +170,19 @@ import type { Series, Season, Episode, Torrent } from '../types';
|
||||
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
|
||||
import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
import ReleaseVersionCard from './ReleaseVersionCard.vue';
|
||||
import ReleaseActionMenu from './ReleaseActionMenu.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
series: Series;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
getRootName: (rootId: string | null | undefined) => string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
play: [string];
|
||||
openFolder: [string];
|
||||
openFolder: [string, string | null | undefined];
|
||||
}>();
|
||||
|
||||
// Focus on matched episode when provided
|
||||
@@ -235,12 +227,16 @@ const versionActionMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
torrent: Torrent | null;
|
||||
filePath: string | null;
|
||||
rootName: string | null;
|
||||
rootId: string | null;
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
torrent: null,
|
||||
filePath: null,
|
||||
rootName: null,
|
||||
rootId: null,
|
||||
});
|
||||
|
||||
const releaseMenuOriginElement = ref<HTMLElement | null>(null);
|
||||
@@ -348,7 +344,9 @@ function closeContextMenu() {
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
versionActionMenu.value.visible = false;
|
||||
versionActionMenu.value.torrent = null;
|
||||
versionActionMenu.value.filePath = null;
|
||||
versionActionMenu.value.rootName = null;
|
||||
versionActionMenu.value.rootId = null;
|
||||
}
|
||||
|
||||
function handleGamepadAction(event: Event) {
|
||||
@@ -384,17 +382,18 @@ function getPlayLabel(filePath: string | null): string {
|
||||
}
|
||||
|
||||
// Open folder for a version
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
function handleOpenFolder(folderPath: string, rootId?: string | null) {
|
||||
if (!folderPath) return;
|
||||
emit('openFolder', folderPath);
|
||||
emit('openFolder', folderPath, rootId);
|
||||
closeVersionActionMenu();
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
|
||||
if (!torrent.playable_file) return;
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
if (event.altKey) {
|
||||
handleOpenFolder(torrent.playable_file);
|
||||
handleOpenFolder(torrent.playable_file, rootId);
|
||||
return;
|
||||
}
|
||||
handlePlayVersion(torrent.playable_file);
|
||||
@@ -402,22 +401,26 @@ function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEve
|
||||
|
||||
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
|
||||
if (!torrent.playable_file) return;
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleOpenFolder(torrent.playable_file);
|
||||
handleOpenFolder(torrent.playable_file, rootId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rootId = torrent.root_id || props.series.root_id;
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
torrent,
|
||||
filePath: torrent.playable_file || null,
|
||||
rootName: props.getRootName(rootId) || null,
|
||||
rootId,
|
||||
};
|
||||
nextTick(() => {
|
||||
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null;
|
||||
@@ -1059,36 +1062,4 @@ html.mouse-active .episode-tile:hover .tile-play {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.version-action-menu {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
min-width: 180px;
|
||||
background: rgba(18, 20, 28, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-action-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html.mouse-active .version-action-item:hover:not(:disabled),
|
||||
.version-action-item:focus-visible:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.version-action-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user