mirror of
https://github.com/Drop-OSS/drop-app.git
synced 2025-11-13 08:12:44 +10:00
feat: prototype big picture mode
This commit is contained in:
13
main/app.vue
13
main/app.vue
@ -1,21 +1,13 @@
|
||||
<template>
|
||||
<NuxtLoadingIndicator color="#2563eb" />
|
||||
<NuxtLayout ref="rootNode" class="select-none w-screen h-screen">
|
||||
<NuxtLayout class="select-none w-screen h-screen">
|
||||
<NuxtPage />
|
||||
<ModalStack />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "~/composables/downloads.js";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useAppState } from "./composables/app-state.js";
|
||||
import {
|
||||
initialNavigation,
|
||||
setupHooks,
|
||||
} from "./composables/state-navigation.js";
|
||||
import { createTVNavigator } from "./composables/tvmode.js";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -42,9 +34,6 @@ router.beforeEach(async () => {
|
||||
await fetchState();
|
||||
});
|
||||
|
||||
const rootNode = ref<HTMLElement>();
|
||||
const navigator = createTVNavigator(rootNode);
|
||||
|
||||
setupHooks();
|
||||
initialNavigation(state);
|
||||
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
<template>
|
||||
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
|
||||
<div class="inline-flex divide-x divide-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
@click="() => buttonActions[props.status.type]()"
|
||||
:class="[
|
||||
styles[props.status.type],
|
||||
showDropdown ? 'rounded-l-md' : 'rounded-md',
|
||||
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="buttonIcons[props.status.type]"
|
||||
class="-mr-0.5 size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ buttonNames[props.status.type] }}
|
||||
</button>
|
||||
<Menu
|
||||
v-if="showDropdown"
|
||||
as="div"
|
||||
class="relative inline-block text-left grow"
|
||||
>
|
||||
<div class="h-full">
|
||||
<MenuButton
|
||||
:class="[
|
||||
styles[props.status.type],
|
||||
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
]"
|
||||
>
|
||||
<ChevronDownIcon class="size-5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"
|
||||
>
|
||||
<div class="py-1">
|
||||
<MenuItem v-if="showOptions" v-slot="{ active }">
|
||||
<button
|
||||
@click="() => emit('options')"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-zinc-800 text-zinc-100 outline-none'
|
||||
: 'text-zinc-400',
|
||||
'w-full block px-4 py-2 text-sm inline-flex justify-between',
|
||||
]"
|
||||
>
|
||||
Options
|
||||
<Cog6ToothIcon class="size-5" />
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button
|
||||
@click="() => emit('uninstall')"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-zinc-800 text-zinc-100 outline-none'
|
||||
: 'text-zinc-400',
|
||||
'w-full block px-4 py-2 text-sm inline-flex justify-between',
|
||||
]"
|
||||
>
|
||||
Uninstall
|
||||
<TrashIcon class="size-5" />
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ChevronDownIcon,
|
||||
PlayIcon,
|
||||
QueueListIcon,
|
||||
ServerIcon,
|
||||
StopIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
|
||||
import type { Component } from "vue";
|
||||
import { GameStatusEnum, type GameStatus } from "~/types.js";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const props = defineProps<{ status: GameStatus }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "install"): void;
|
||||
(e: "launch"): void;
|
||||
(e: "queue"): void;
|
||||
(e: "uninstall"): void;
|
||||
(e: "kill"): void;
|
||||
(e: "options"): void;
|
||||
(e: "resume"): void;
|
||||
}>();
|
||||
|
||||
const showDropdown = computed(
|
||||
() =>
|
||||
props.status.type === GameStatusEnum.Installed ||
|
||||
props.status.type === GameStatusEnum.SetupRequired ||
|
||||
props.status.type === GameStatusEnum.PartiallyInstalled
|
||||
);
|
||||
|
||||
const showOptions = computed(
|
||||
() => props.status.type === GameStatusEnum.Installed
|
||||
);
|
||||
|
||||
const styles: { [key in GameStatusEnum]: string } = {
|
||||
[GameStatusEnum.Remote]:
|
||||
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
|
||||
[GameStatusEnum.Queued]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.Downloading]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.Validating]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.SetupRequired]:
|
||||
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
|
||||
[GameStatusEnum.Installed]:
|
||||
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
|
||||
[GameStatusEnum.Updating]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.Uninstalling]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.Running]:
|
||||
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
|
||||
[GameStatusEnum.PartiallyInstalled]:
|
||||
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
|
||||
};
|
||||
|
||||
const buttonNames: { [key in GameStatusEnum]: string } = {
|
||||
[GameStatusEnum.Remote]: "Install",
|
||||
[GameStatusEnum.Queued]: "Queued",
|
||||
[GameStatusEnum.Downloading]: "Downloading",
|
||||
[GameStatusEnum.Validating]: "Validating",
|
||||
[GameStatusEnum.SetupRequired]: "Setup",
|
||||
[GameStatusEnum.Installed]: "Play",
|
||||
[GameStatusEnum.Updating]: "Updating",
|
||||
[GameStatusEnum.Uninstalling]: "Uninstalling",
|
||||
[GameStatusEnum.Running]: "Stop",
|
||||
[GameStatusEnum.PartiallyInstalled]: "Resume",
|
||||
};
|
||||
|
||||
const buttonIcons: { [key in GameStatusEnum]: Component } = {
|
||||
[GameStatusEnum.Remote]: ArrowDownTrayIcon,
|
||||
[GameStatusEnum.Queued]: QueueListIcon,
|
||||
[GameStatusEnum.Downloading]: ArrowDownTrayIcon,
|
||||
[GameStatusEnum.Validating]: ServerIcon,
|
||||
[GameStatusEnum.SetupRequired]: WrenchIcon,
|
||||
[GameStatusEnum.Installed]: PlayIcon,
|
||||
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
|
||||
[GameStatusEnum.Uninstalling]: TrashIcon,
|
||||
[GameStatusEnum.Running]: StopIcon,
|
||||
[GameStatusEnum.PartiallyInstalled]: ArrowDownTrayIcon,
|
||||
};
|
||||
|
||||
const buttonActions: { [key in GameStatusEnum]: () => void } = {
|
||||
[GameStatusEnum.Remote]: () => emit("install"),
|
||||
[GameStatusEnum.Queued]: () => emit("queue"),
|
||||
[GameStatusEnum.Downloading]: () => emit("queue"),
|
||||
[GameStatusEnum.Validating]: () => emit("queue"),
|
||||
[GameStatusEnum.SetupRequired]: () => emit("launch"),
|
||||
[GameStatusEnum.Installed]: () => emit("launch"),
|
||||
[GameStatusEnum.Updating]: () => emit("queue"),
|
||||
[GameStatusEnum.Uninstalling]: () => {},
|
||||
[GameStatusEnum.Running]: () => emit("kill"),
|
||||
[GameStatusEnum.PartiallyInstalled]: () => emit("resume"),
|
||||
};
|
||||
</script>
|
||||
@ -76,7 +76,6 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||
import type { NavigationItem } from "../types";
|
||||
import HeaderWidget from "./HeaderWidget.vue";
|
||||
import { useAppState } from "~/composables/app-state";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-x-2">
|
||||
<div class="flex flex-col gap-x-2">
|
||||
<p
|
||||
class="text-sm whitespace-nowrap font-display font-semibold"
|
||||
>
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg class="text-blue-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
|
||||
stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
class="inline-flex items-center gap-x-2 px-1 py-0.5 rounded bg-blue-900 text-zinc-100 hover:bg-blue-800"
|
||||
>
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
|
||||
<svg aria-hidden="true" viewBox="0 0 418 42" class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
|
||||
preserveAspectRatio="none">
|
||||
<path
|
||||
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
|
||||
</svg>
|
||||
<Logo class="h-6" />
|
||||
<span class="text-blue-400 font-display font-bold text-xl uppercase">Drop</span>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,3 +0,0 @@
|
||||
import type { AppState } from "~/types";
|
||||
|
||||
export const useAppState = () => useState<AppState | undefined>("state");
|
||||
@ -1,32 +0,0 @@
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { NavigationItem } from "~/types";
|
||||
|
||||
export const useCurrentNavigationIndex = (
|
||||
navigation: Array<NavigationItem>
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const currentNavigation = ref(-1);
|
||||
|
||||
function calculateCurrentNavIndex(to: RouteLocationNormalized) {
|
||||
const validOptions = navigation
|
||||
.map((e, i) => ({ ...e, index: i }))
|
||||
.filter((e) => to.fullPath.startsWith(e.prefix));
|
||||
const bestOption = validOptions
|
||||
.sort((a, b) => b.route.length - a.route.length)
|
||||
.at(0);
|
||||
|
||||
return bestOption?.index ?? -1;
|
||||
}
|
||||
|
||||
currentNavigation.value = calculateCurrentNavIndex(route);
|
||||
|
||||
router.afterEach((to) => {
|
||||
currentNavigation.value = calculateCurrentNavIndex(to);
|
||||
});
|
||||
|
||||
return {currentNavigation, recalculateNavigation: () => {
|
||||
currentNavigation.value = calculateCurrentNavIndex(route);
|
||||
}};
|
||||
};
|
||||
@ -1,36 +0,0 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { DownloadableMetadata } from "~/types";
|
||||
|
||||
export type QueueState = {
|
||||
queue: Array<{
|
||||
meta: DownloadableMetadata;
|
||||
status: string;
|
||||
progress: number | null;
|
||||
current: number;
|
||||
max: number;
|
||||
}>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type StatsState = {
|
||||
speed: number; // Bytes per second
|
||||
time: number; // Seconds,
|
||||
};
|
||||
|
||||
export const useQueueState = () =>
|
||||
useState<QueueState>("queue", () => ({ queue: [], status: "Unknown" }));
|
||||
|
||||
export const useStatsState = () =>
|
||||
useState<StatsState>("stats", () => ({ speed: 0, time: 0 }));
|
||||
|
||||
listen("update_queue", (event) => {
|
||||
const queue = useQueueState();
|
||||
queue.value = event.payload as QueueState;
|
||||
});
|
||||
|
||||
listen("update_stats", (event) => {
|
||||
const stats = useStatsState();
|
||||
stats.value = event.payload as StatsState;
|
||||
});
|
||||
|
||||
export const useDownloadHistory = () => useState<Array<number>>('history', () => []);
|
||||
@ -1,74 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types";
|
||||
|
||||
const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } =
|
||||
{};
|
||||
|
||||
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
|
||||
|
||||
type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } };
|
||||
export type SerializedGameStatus = [
|
||||
{ type: GameStatusEnum },
|
||||
OptionGameStatus | null
|
||||
];
|
||||
|
||||
export const parseStatus = (status: SerializedGameStatus): GameStatus => {
|
||||
if (status[0]) {
|
||||
return {
|
||||
type: status[0].type,
|
||||
};
|
||||
} else if (status[1]) {
|
||||
const [[gameStatus, options]] = Object.entries(status[1]);
|
||||
return {
|
||||
type: gameStatus as GameStatusEnum,
|
||||
...options,
|
||||
};
|
||||
} else {
|
||||
throw new Error("No game status");
|
||||
}
|
||||
};
|
||||
|
||||
export const useGame = async (gameId: string) => {
|
||||
if (!gameRegistry[gameId]) {
|
||||
const data: {
|
||||
game: Game;
|
||||
status: SerializedGameStatus;
|
||||
version?: GameVersion;
|
||||
} = await invoke("fetch_game", {
|
||||
gameId,
|
||||
});
|
||||
gameRegistry[gameId] = { game: data.game, version: data.version };
|
||||
if (!gameStatusRegistry[gameId]) {
|
||||
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
|
||||
|
||||
listen(`update_game/${gameId}`, (event) => {
|
||||
console.log(event);
|
||||
const payload: {
|
||||
status: SerializedGameStatus;
|
||||
version?: GameVersion;
|
||||
} = event.payload as any;
|
||||
gameStatusRegistry[gameId].value = parseStatus(payload.status);
|
||||
|
||||
/**
|
||||
* I am not super happy about this.
|
||||
*
|
||||
* This will mean that we will still have a version assigned if we have a game installed then uninstall it.
|
||||
* It is necessary because a flag to check if we should overwrite seems excessive, and this function gets called
|
||||
* on transient state updates.
|
||||
*/
|
||||
if (payload.version) {
|
||||
gameRegistry[gameId].version = payload.version;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const game = gameRegistry[gameId];
|
||||
const status = gameStatusRegistry[gameId];
|
||||
return { ...game, status };
|
||||
};
|
||||
|
||||
export type FrontendGameConfiguration = {
|
||||
launchString: string;
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
import { type DownloadableMetadata, DownloadableType } from '~/types'
|
||||
|
||||
export default function generateGameMeta(gameId: string, version: string): DownloadableMetadata {
|
||||
return {
|
||||
id: gameId,
|
||||
version,
|
||||
downloadType: DownloadableType.Game
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { data } from "autoprefixer";
|
||||
import { AppStatus, type AppState } from "~/types";
|
||||
|
||||
export function setupHooks() {
|
||||
const router = useRouter();
|
||||
const state = useAppState();
|
||||
|
||||
listen("auth/processing", (event) => {
|
||||
router.push("/auth/processing");
|
||||
});
|
||||
|
||||
listen("auth/failed", (event) => {
|
||||
router.push(
|
||||
`/auth/failed?error=${encodeURIComponent(event.payload as string)}`
|
||||
);
|
||||
});
|
||||
|
||||
listen("auth/finished", async (event) => {
|
||||
router.push("/library");
|
||||
state.value = JSON.parse(await invoke("fetch_state"));
|
||||
});
|
||||
|
||||
listen("download_error", (event) => {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Drop encountered an error while downloading",
|
||||
description: `Drop encountered an error while downloading your game: "${(
|
||||
event.payload as unknown as string
|
||||
).toString()}"`,
|
||||
buttonText: "Close",
|
||||
},
|
||||
(e, c) => c()
|
||||
);
|
||||
});
|
||||
|
||||
// This is for errors that (we think) aren't our fault
|
||||
listen("launch_external_error", (event) => {
|
||||
createModal(
|
||||
ModalType.Confirmation,
|
||||
{
|
||||
title: "Did something go wrong?",
|
||||
description:
|
||||
"Drop detected that something might've gone wrong with launching your game. Do you want to open the log directory?",
|
||||
buttonText: "Open",
|
||||
},
|
||||
async (e, c) => {
|
||||
if (e == "confirm") {
|
||||
await invoke("open_process_logs", { gameId: event.payload });
|
||||
}
|
||||
c();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
event.target?.dispatchEvent(new Event("contextmenu"));
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
export function initialNavigation(state: ReturnType<typeof useAppState>) {
|
||||
if (!state.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "App state not valid",
|
||||
fatal: true,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
switch (state.value.status) {
|
||||
case AppStatus.NotConfigured:
|
||||
router.push({ path: "/setup" });
|
||||
break;
|
||||
case AppStatus.SignedOut:
|
||||
router.push("/auth");
|
||||
break;
|
||||
case AppStatus.SignedInNeedsReauth:
|
||||
router.push("/auth/signedout");
|
||||
break;
|
||||
case AppStatus.ServerUnavailable:
|
||||
router.push("/error/serverunavailable");
|
||||
break;
|
||||
default:
|
||||
router.push("/library");
|
||||
}
|
||||
}
|
||||
@ -1,246 +0,0 @@
|
||||
const NAVIGATE_MODIFIED_PROP = "tvnav-id";
|
||||
const NAVIGATE_INTERACT_ID = "tvnav-iid";
|
||||
|
||||
const Directions = ["left", "right", "up", "down"] as const;
|
||||
type Direction = (typeof Directions)[number];
|
||||
|
||||
const NAVIGATE_LEFT_ID = "tvnav-left";
|
||||
const NAVIGATE_RIGHT_ID = "tvnav-right";
|
||||
const NAVIGATE_UP_ID = "tvnav-up";
|
||||
const NAVIGATE_DOWN_ID = "tvnav-down";
|
||||
|
||||
interface NavigationJump {
|
||||
distance: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type Position = [number, number, number, number];
|
||||
|
||||
class TVModeNavigator {
|
||||
private rootNode: Ref<HTMLElement | undefined>;
|
||||
private navigationNodes: Map<string, Array<HTMLElement>> = new Map();
|
||||
|
||||
constructor(rootNode: Ref<HTMLElement | undefined>) {
|
||||
this.rootNode = rootNode;
|
||||
|
||||
const thisRef = this;
|
||||
const observer = new MutationObserver((v, k) => {
|
||||
this.onMutation(thisRef, v, k);
|
||||
});
|
||||
observer.observe(document.getRootNode(), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
switch (ev.code) {
|
||||
case "KeyW":
|
||||
return this.moveUp();
|
||||
case "KeyS":
|
||||
return this.moveDown();
|
||||
case "KeyD":
|
||||
return this.moveRight();
|
||||
case "KeyA":
|
||||
return this.moveLeft();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getCurrentPosition(element?: HTMLElement): Position {
|
||||
const el = element || document.activeElement;
|
||||
if (!el) throw "No active position";
|
||||
const rect = el.getBoundingClientRect();
|
||||
return [rect.left, rect.right, rect.top, rect.bottom];
|
||||
}
|
||||
|
||||
private isSamePosition(a: Position, b: Position) {
|
||||
return a.map((v, i) => v === b[i]).every((v) => v);
|
||||
}
|
||||
|
||||
private findElementWithPredicate(
|
||||
distanceCalculator: (current: Position, target: Position) => number,
|
||||
check: (current: Position, target: Position) => boolean
|
||||
) {
|
||||
const current = this.getCurrentPosition();
|
||||
// We want things in the x direction, with a limit on the y
|
||||
let distance = Math.max(window.innerWidth, window.innerHeight);
|
||||
let element = null;
|
||||
for (const newElement of this.navigationNodes.values().toArray().flat()) {
|
||||
const target = this.getCurrentPosition(newElement);
|
||||
if(this.isSamePosition(current, target)) continue;
|
||||
const newDistance = distanceCalculator(current, target);
|
||||
// If we're the wrong way, or further than the current option
|
||||
if (newDistance < 0 || newDistance > distance) continue;
|
||||
if (check(current, target)) continue;
|
||||
distance = newDistance;
|
||||
element = newElement;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
moveUp() {
|
||||
const leeway = 20; // 20px
|
||||
const element = this.findElementWithPredicate(
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
ytop - ebottom,
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
xleft - leeway > eright && xright + leeway < eleft
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
moveDown() {
|
||||
const leeway = 20; // 20px
|
||||
const element = this.findElementWithPredicate(
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
etop - ybottom,
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
xleft - leeway > eright && xright + leeway < eleft
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
moveRight() {
|
||||
const leeway = 20; // 20px
|
||||
const element = this.findElementWithPredicate(
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
eleft - xright,
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
etop - leeway > ytop && ebottom + leeway < etop
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
moveLeft() {
|
||||
const leeway = 20; // 20px
|
||||
const element = this.findElementWithPredicate(
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
xleft - eright,
|
||||
([xleft, xright, ytop, ybottom], [eleft, eright, etop, ebottom]) =>
|
||||
etop - leeway > ytop && ebottom + leeway < etop
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
recursivelyFindInteractable(element: Element): Array<HTMLElement> {
|
||||
const elements = [];
|
||||
for (const child of element.children) {
|
||||
if (!child) continue;
|
||||
if (child instanceof HTMLAnchorElement) {
|
||||
elements.push(child);
|
||||
continue;
|
||||
}
|
||||
if (child instanceof HTMLButtonElement) {
|
||||
elements.push(child);
|
||||
continue;
|
||||
}
|
||||
if(child instanceof HTMLInputElement) {
|
||||
elements.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save ourselves a function call
|
||||
if (child.children.length > 0) {
|
||||
elements.push(...this.recursivelyFindInteractable(child));
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
getInteractionId(element: Element) {
|
||||
const id = element.getAttribute(NAVIGATE_INTERACT_ID);
|
||||
if (id) return id;
|
||||
const newId = crypto.randomUUID();
|
||||
element.setAttribute(NAVIGATE_INTERACT_ID, newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
private getNavJumpKey(direction: Direction) {
|
||||
switch (direction) {
|
||||
case "down":
|
||||
return NAVIGATE_DOWN_ID;
|
||||
case "left":
|
||||
return NAVIGATE_LEFT_ID;
|
||||
case "right":
|
||||
return NAVIGATE_RIGHT_ID;
|
||||
case "up":
|
||||
return NAVIGATE_UP_ID;
|
||||
}
|
||||
|
||||
throw "Invalid direction";
|
||||
}
|
||||
|
||||
getNavJump(
|
||||
element: Element,
|
||||
direction: Direction
|
||||
): NavigationJump | undefined {
|
||||
const key = this.getNavJumpKey(direction);
|
||||
const value = element.getAttribute(key);
|
||||
if (!value) return undefined;
|
||||
const [id, distance] = value.split("/");
|
||||
return {
|
||||
distance: parseFloat(distance),
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
onMutation(
|
||||
self: TVModeNavigator,
|
||||
mutationlist: Array<MutationRecord>,
|
||||
observer: unknown
|
||||
) {
|
||||
for (const mutation of mutationlist) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const el = node as Element;
|
||||
const id = el.getAttribute(NAVIGATE_MODIFIED_PROP);
|
||||
if (id) {
|
||||
self.navigationNodes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!node) continue;
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const el = node as Element;
|
||||
|
||||
const existingId = el.getAttribute(NAVIGATE_MODIFIED_PROP);
|
||||
if (existingId) {
|
||||
self.navigationNodes.delete(existingId);
|
||||
}
|
||||
|
||||
const interactiveNodes = self.recursivelyFindInteractable(el);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
el.setAttribute(NAVIGATE_MODIFIED_PROP, id);
|
||||
|
||||
self.navigationNodes.set(id, interactiveNodes);
|
||||
}
|
||||
}
|
||||
|
||||
const interactiveElements = this.navigationNodes.values().toArray().flat();
|
||||
|
||||
// Set focus so we aren't confused
|
||||
if (!document.activeElement || document.activeElement.tagName === "BODY") {
|
||||
const active = interactiveElements.at(0);
|
||||
if (active) {
|
||||
active.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createTVNavigator = (rootNode: Ref<HTMLElement | undefined>) =>
|
||||
new TVModeNavigator(rootNode);
|
||||
@ -1,5 +0,0 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
export const useObject = async (id: string) => {
|
||||
return convertFileSrc(id, "object");
|
||||
};
|
||||
@ -9,11 +9,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
css: ["~/assets/main.scss"],
|
||||
|
||||
ssr: false,
|
||||
|
||||
extends: [["../libs/drop-base"]],
|
||||
extends: ["../shared", "../libs/drop-base"],
|
||||
|
||||
app: {
|
||||
baseURL: "/main",
|
||||
@ -22,4 +20,6 @@ export default defineNuxtConfig({
|
||||
devtools: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Also possible
|
||||
/*
|
||||
nuxtApp.hook("vue:error", (error, instance, info) => {
|
||||
|
||||
console.error(error, info);
|
||||
const router = useRouter();
|
||||
router.replace(`/error`);
|
||||
});
|
||||
*/
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component("draggable", draggable);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -7,6 +7,7 @@ export default {
|
||||
"./plugins/**/*.{js,ts}",
|
||||
"./app.vue",
|
||||
"./error.vue",
|
||||
"../shared/components/**/*.vue"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user