Add MPC-BE remote control and resume support.
This commit is contained in:
+80
-1
@@ -32,6 +32,7 @@
|
||||
<Header
|
||||
:current-view="currentView"
|
||||
:search-query="searchQuery"
|
||||
:mpc-be-connected="mpcBeConnected"
|
||||
:nav-row="1"
|
||||
:position="headerPosition"
|
||||
@search="searchQuery = $event"
|
||||
@@ -66,6 +67,7 @@
|
||||
v-else-if="selectedItem"
|
||||
:item="selectedItem"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@close="closeDetail"
|
||||
@play="handlePlay"
|
||||
@open-folder="handleOpenFolder"
|
||||
@@ -83,6 +85,7 @@
|
||||
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
|
||||
:items="movieCollageItems"
|
||||
:featured-item="movieFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -110,6 +113,7 @@
|
||||
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
|
||||
:items="seriesCollageItems"
|
||||
:featured-item="seriesFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -141,6 +145,7 @@
|
||||
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
|
||||
:items="searchCollageItems"
|
||||
:featured-item="searchFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -179,7 +184,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types';
|
||||
import { playMedia, openFolder } from './api';
|
||||
import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath } from './api';
|
||||
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
||||
import { useMediaWebSocket } from './composables/useMediaWebSocket';
|
||||
import Header from './components/Header.vue';
|
||||
@@ -201,6 +206,70 @@ const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||
|
||||
const searchResults = ref<MediaItem[]>([]);
|
||||
const isSearching = ref(false);
|
||||
const mpcBeConnected = ref(false);
|
||||
const resumePositions = ref<Record<string, number>>({});
|
||||
const MPC_BE_OPENING_GRACE_MS = 4000;
|
||||
const mpcBeOpeningUntil = ref(0);
|
||||
let mpcBePollTimer: number | null = null;
|
||||
|
||||
function isMpcBeGamepadCaptured() {
|
||||
return mpcBeConnected.value || Date.now() < mpcBeOpeningUntil.value;
|
||||
}
|
||||
|
||||
function stopMpcBePolling() {
|
||||
if (mpcBePollTimer !== null) {
|
||||
window.clearInterval(mpcBePollTimer);
|
||||
mpcBePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshResumePositions() {
|
||||
resumePositions.value = await fetchResumePositions();
|
||||
}
|
||||
|
||||
function hasResumePosition(filePath: string | null) {
|
||||
if (!filePath) return false;
|
||||
const normalizedPath = normalizeMediaPath(filePath);
|
||||
return Number(resumePositions.value[normalizedPath] || 0) > 0;
|
||||
}
|
||||
|
||||
function startMpcBePolling() {
|
||||
if (mpcBePollTimer !== null) return;
|
||||
mpcBePollTimer = window.setInterval(async () => {
|
||||
const reachable = await isMpcBeReachable();
|
||||
const wasConnected = mpcBeConnected.value;
|
||||
mpcBeConnected.value = reachable;
|
||||
if (!reachable) {
|
||||
stopMpcBePolling();
|
||||
if (wasConnected) {
|
||||
void refreshResumePositions();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function tryConnectMpcBe(attempts = 8, delayMs = 400): Promise<boolean> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const reachable = await isMpcBeReachable();
|
||||
if (reachable) return true;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back';
|
||||
|
||||
function onGamepadAction(event: Event) {
|
||||
const customEvent = event as CustomEvent<{ action?: GamepadAction }>;
|
||||
const action = customEvent.detail?.action;
|
||||
if (!action) return;
|
||||
|
||||
if (isMpcBeGamepadCaptured()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Focus episode info for navigating to series detail from search
|
||||
const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null);
|
||||
@@ -311,13 +380,17 @@ function handleEscapeKey(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshResumePositions();
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
window.addEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
||||
window.addEventListener('click', requestInitialFullscreen, { once: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
window.removeEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
||||
window.removeEventListener('click', requestInitialFullscreen);
|
||||
stopMpcBePolling();
|
||||
});
|
||||
|
||||
// Search query stored in ref (not URL-based)
|
||||
@@ -1110,8 +1183,14 @@ function reloadPage() {
|
||||
}
|
||||
|
||||
async function handlePlay(filePath: string) {
|
||||
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS;
|
||||
try {
|
||||
await playMedia(filePath);
|
||||
const connected = await tryConnectMpcBe();
|
||||
if (connected) {
|
||||
mpcBeConnected.value = true;
|
||||
startMpcBePolling();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play media:', e);
|
||||
}
|
||||
|
||||
+39
-4
@@ -1,5 +1,12 @@
|
||||
import type { MediaIndex } from './types';
|
||||
|
||||
export function normalizeMediaPath(input: string): string {
|
||||
return input
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^[A-Za-z]:\//, '')
|
||||
.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the media index from the server
|
||||
*/
|
||||
@@ -15,11 +22,12 @@ export async function loadMediaIndex(): Promise<MediaIndex> {
|
||||
* Play a media file with the system's default player
|
||||
*/
|
||||
export async function playMedia(filePath: string): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(filePath);
|
||||
try {
|
||||
const response = await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
body: JSON.stringify({ file_path: normalizedPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -27,7 +35,7 @@ export async function playMedia(filePath: string): Promise<void> {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Play media error:', e);
|
||||
alert(`Failed to play: ${e}`);
|
||||
alert(`Failed to play media.\n\n${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +43,12 @@ export async function playMedia(filePath: string): Promise<void> {
|
||||
* Open a folder in Windows Explorer
|
||||
*/
|
||||
export async function openFolder(folderPath: string): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(folderPath);
|
||||
try {
|
||||
const response = await fetch('/api/open-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath }),
|
||||
body: JSON.stringify({ folder_path: normalizedPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -47,7 +56,33 @@ export async function openFolder(folderPath: string): Promise<void> {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open folder error:', e);
|
||||
alert(`Failed to open folder: ${e}`);
|
||||
alert(`Failed to open folder.\n\n${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether MPC-BE local web control is currently reachable.
|
||||
*/
|
||||
export async function isMpcBeReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/mpcbe/status');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return Boolean(data.reachable);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const response = await fetch('/api/playback/resume-positions');
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const resumePositions = data?.resume_positions;
|
||||
return resumePositions && typeof resumePositions === 'object' ? resumePositions : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
|
||||
<div class="collage-buttons">
|
||||
<button class="btn btn-primary" @click.stop="handlePlay(item)">▶ Play</button>
|
||||
<button class="btn btn-primary" @click.stop="handlePlay(item)">▶ {{ getPlayLabel(item) }}</button>
|
||||
<button class="btn btn-secondary" @click.stop="$emit('info', item)">ℹ Info</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,6 +416,7 @@ function isItemVisible(index: number): boolean {
|
||||
const props = defineProps<{
|
||||
items: MediaItem[];
|
||||
featuredItem?: MediaItem | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -530,6 +531,10 @@ function handlePlay(item: MediaItem) {
|
||||
if (file) emit('play', file);
|
||||
}
|
||||
|
||||
function getPlayLabel(item: MediaItem): string {
|
||||
return props.hasResumePosition(getPlayableFile(item)) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem, index: number) {
|
||||
if (index === 0) {
|
||||
emit('info', item);
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected">
|
||||
<span class="player-indicator-dot" aria-hidden="true"></span>
|
||||
<span>Player Open</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isDesktopApp" class="header-settings">
|
||||
<button
|
||||
class="header-settings-btn"
|
||||
@@ -81,6 +86,7 @@ import { pickFolderAndRestart } from '../api';
|
||||
const props = defineProps<{
|
||||
currentView: 'movies' | 'series';
|
||||
searchQuery: string;
|
||||
mpcBeConnected: boolean;
|
||||
navRow: number;
|
||||
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
|
||||
}>();
|
||||
@@ -180,3 +186,22 @@ onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.player-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-if="item.type === 'series'"
|
||||
:series="item.data as Series"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@close="$emit('close')"
|
||||
@play="handlePlay"
|
||||
@openFolder="handleOpenFolder"
|
||||
@@ -95,7 +96,7 @@
|
||||
v-bind="navAttrs(2, index * 2)"
|
||||
@click="handlePlay(version.playable_file)"
|
||||
:disabled="!version.playable_file"
|
||||
>▶ Play</button>
|
||||
>▶ {{ getPlayLabel(version.playable_file) }}</button>
|
||||
<button
|
||||
class="btn btn-small btn-secondary"
|
||||
v-bind="navAttrs(2, index * 2 + 1)"
|
||||
@@ -148,6 +149,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -394,6 +396,10 @@ function handlePlay(filePath: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
emit('openFolder', folderPath);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
tabindex="0"
|
||||
@click="handlePlayVersion(torrent.playable_file)"
|
||||
:disabled="!torrent.playable_file"
|
||||
>▶ Play</button>
|
||||
>▶ {{ getPlayLabel(torrent.playable_file) }}</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@@ -164,6 +164,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
const props = defineProps<{
|
||||
series: Series;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -302,6 +303,10 @@ function handlePlayVersion(filePath: string | null) {
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
// Open folder for a version
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
emit('openFolder', folderPath);
|
||||
|
||||
@@ -54,6 +54,13 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb
|
||||
if (!canTrigger) return;
|
||||
|
||||
gamepadLastTriggerAt[action] = now;
|
||||
const actionEvent = new CustomEvent('mediahive:gamepad-action', {
|
||||
detail: { action },
|
||||
cancelable: true,
|
||||
});
|
||||
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent);
|
||||
if (!shouldContinueWithKeyboard) return;
|
||||
|
||||
dispatchKey(KEY_BY_ACTION[action]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user