Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for spatial navigation (Navigation using arrow keys) and also support for tv remote keys #1885

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = {
ignorePatterns: [
'.eslintrc.js',
'*.config.js',
'spatialNavigation.js',
'types/global/routes.d.ts',
'types/global/components.d.ts'
],
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<backdrop />
<v-app>
<v-app v-focus-section:app>
<router-view-transition is-root />
<snackbar />
</v-app>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/assets/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,9 @@ html.no-forced-scrollbar {
top: 1em;
right: 1em;
}

button.bg-primary:focus,
button.bg-primary:focus-visible {
box-shadow: 0 0 0 5px white !important;
outline: none !important;
}
2 changes: 2 additions & 0 deletions frontend/src/components/Buttons/Playback/PlayButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div class="d-inline-flex">
<v-btn
v-if="canPlay(item) && (fab || iconOnly)"
v-focus
:variant="iconOnly ? undefined : 'elevated'"
:color="iconOnly ? undefined : 'primary'"
icon
Expand All @@ -17,6 +18,7 @@
</v-btn>
<v-btn
v-else-if="!fab"
v-focus
:disabled="disabled || !canPlay(item)"
:loading="loading"
class="mr-2"
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/components/Item/Card/Card.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<template>
<div :class="{ 'card-margin': margin }">
<div
v-focus="!!link"
:class="{ 'card-margin': margin }"
@keyup.enter="cardClicked($router)">
<component
:is="link ? 'router-link' : 'div'"
:to="link ? getItemDetailsLink(item) : null"
Expand Down Expand Up @@ -97,6 +100,8 @@ const isFinePointer = useMediaQuery('(pointer:fine)');
</script>

<script lang="ts" setup>
import { Router } from 'vue-router';

const props = withDefaults(
defineProps<{
item: BaseItemDto;
Expand Down Expand Up @@ -128,6 +133,15 @@ const cardTitle = computed(() =>
: props.item.Name || ''
);

/**
* Takes you to subtitle link or cardlink if no subtitle link
*/
function cardClicked(router: Router): void {
if (props.link && (cardTitleLink.value || cardSubtitleLink.value)) {
router.push(cardSubtitleLink.value || cardTitleLink.value);
}
}

/**
* Returns either a string representing the production year(s) for the current item
* or the episode name of an item (SX EY - Episode Name)
Expand Down Expand Up @@ -308,4 +322,12 @@ const refreshProgress = computed(
.absolute {
position: absolute;
}

.card-margin:focus-within,
.card-margin:focus {
box-shadow: 0 0 0 5px rgb(var(--v-theme-primary));
border-radius: 5px;
outline: none;
background-color: rgb(var(--v-theme-primary));
}
</style>
38 changes: 34 additions & 4 deletions frontend/src/components/Item/SeasonTabs.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
<template>
<div>
<v-tabs v-model="currentTab" class="mb-3" bg-color="transparent">
<v-tab v-for="season in seasons" :key="season.Id">
<v-tabs
v-model="currentTab"
v-focus-section:season-tab="{
leaveFor: { down: '@season-episodes', left: '@nav' }
}"
class="mb-3"
bg-color="transparent">
<v-tab v-for="season in seasons" :key="season.Id" v-focus>
{{ season.Name }}
</v-tab>
</v-tabs>
<v-window v-model="currentTab" class="bg-transparent">
<v-window-item v-for="season in seasons" :key="season.Id">
<v-list
v-if="seasonEpisodes && season.Id"
v-focus-section:season-episodes="{
leaveFor: { up: '@season-tab', down: '@season-tab', left: '@nav' }
}"
:lines="false"
bg-color="transparent">
<v-list-item
v-for="episode in seasonEpisodes[season.Id]"
:key="episode.Id"
:to="getItemDetailsLink(episode)">
v-focus
@click="viewDetails($router, episode)">
<v-row align="center" class="my-4">
<v-col
:class="{ 'py-1': $vuetify.display.smAndDown }"
Expand Down Expand Up @@ -44,10 +54,15 @@
</template>

<script setup lang="ts">
import { BaseItemDto, ItemFields } from '@jellyfin/sdk/lib/generated-client';
import {
BaseItemDto,
BaseItemPerson,
ItemFields
} from '@jellyfin/sdk/lib/generated-client';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api';
import { ref, watch } from 'vue';
import { Router } from 'vue-router';
import { getItemDetailsLink } from '@/utils/items';
import { useRemote } from '@/composables';

Expand Down Expand Up @@ -102,8 +117,23 @@ async function fetch(): Promise<void> {
}
}

/**
* Navigates to the episode details.
*/
function viewDetails(router: Router, item: BaseItemDto | BaseItemPerson): void {
router.push(getItemDetailsLink(item));
}

await fetch();
watch(props, async () => {
await fetch();
});
</script>
<style lang="scss" scoped>
button.v-tab:focus,
button.v-tab:focus-visible {
outline: none !important;
color: white !important;
background-color: rgb(var(--v-theme-primary)) !important;
}
</style>
43 changes: 39 additions & 4 deletions frontend/src/components/Layout/Navigation/NavigationDrawer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<v-navigation-drawer
ref="navDrawer"
v-model="drawer"
:temporary="$vuetify.display.mobile"
:permanent="!$vuetify.display.mobile"
Expand All @@ -9,22 +10,32 @@
:color="
transparentLayout && !$vuetify.display.mobile ? 'transparent' : undefined
">
<v-list nav>
<v-list
v-focus-section:nav="{
leaveFor: { right: '@app, @lib-grid, @series, @main', left: '@app' }
}"
nav>
<v-list-item
v-for="item in items"
:key="item.to"
v-focus-events="{ unfocused: handleUnfocus }"
v-focus
:to="item.to"
exact
:prepend-icon="item.icon"
:title="item.title" />
:title="item.title"
@focus="handleFocus" />
<v-list-subheader>{{ $t('libraries') }}</v-list-subheader>
<v-list-item
v-for="library in drawerItems"
:key="library.to"
v-focus-events="{ unfocused: handleUnfocus }"
v-focus
:to="library.to"
exact
:prepend-icon="library.icon"
:title="library.title" />
:title="library.title"
@focus="handleFocus" />
</v-list>
<template #append>
<v-list nav>
Expand All @@ -38,8 +49,9 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { computed, inject, Ref } from 'vue';
import { computed, inject, ref, Ref } from 'vue';
import IMdiHome from 'virtual:icons/mdi/home';
import { VNavigationDrawer } from 'vuetify/components/VNavigationDrawer';
import { userLibrariesStore } from '@/store';
import { getLibraryIcon } from '@/utils/items';

Expand All @@ -52,6 +64,7 @@ const props = defineProps<{
}>();

const drawer = inject<Ref<boolean>>('NavigationDrawer');
const navDrawer = ref<VNavigationDrawer>(null);

const transparentLayout = computed(() => {
return route.meta.transparentLayout || false;
Expand All @@ -67,6 +80,28 @@ const drawerItems = computed(() => {
});
});

/**
* Opens the drawer when any item in it gains focus
*/
function handleFocus(): void {
if (navDrawer.value.temporary && !drawer.value) {
drawer.value = true;
}
}
/**
* Closes the drawer when focus is law
*/
function handleUnfocus(
nextElement: HTMLElement,
nextSectionId: string,
direction: 'left' | 'right' | 'up' | 'down',
native: boolean
): void {
if (navDrawer.value.temporary && nextSectionId != 'nav') {
drawer.value = false;
}
}

const items = [
{
icon: IMdiHome,
Expand Down
39 changes: 38 additions & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
*/

import { createApp } from 'vue';

import Root from '@/App.vue';
/* eslint-disable no-restricted-imports */
import { createRemote, i18n, router, vuetify } from '@/plugins';
import { hideDirective } from '@/plugins/directives';
/* eslint-enable no-restricted-imports */

/**
* - GLOBAL STYLES -
*/
import '@/assets/styles/global.scss';
import '@fontsource/roboto';
import vueSpatialNavigation, { vjsnOptions } from '@/plugins/spatialNav';

/**
* - VUE PLUGINS, STORE AND DIRECTIVE -
Expand All @@ -29,7 +30,43 @@ app.use(i18n);
app.use(router);
app.use(remote);
app.use(vuetify);

app.directive('hide', hideDirective);
app.use(vueSpatialNavigation, {
straightOnly: false,
straightOverlapThreshold: 0.5,
rememberSource: false,
disabled: false,
defaultElement: '',
enterTo: '',
leaveFor: undefined,
restrict: 'self-first',
tabIndexIgnoreList:
'a, input, select, textarea, button, iframe, [contentEditable=true], body',
navigableFilter: (e: HTMLElement): boolean => {
if (e.tagName.toLowerCase() == 'body') {
return false;
}

if (e?.parentElement?.parentElement?.classList?.contains('card-overlay')) {
return false;
}

// virtual grid used in library duplicates the first item on the grid. This causes a bug that prevents the first
// item on the grid from being focusable using spatial navigation. This condition tells the plugin to ignore that element.
if (
e?.parentElement?.style?.opacity == '0' &&
e?.parentElement?.style?.visibility == 'hidden'
) {
console.log('ignoringh v item');

return false;
}

return true;
},
scrollOptions: { behavior: 'smooth', block: 'nearest' }
} as vjsnOptions);

/**
* This ensures the transition plays: https://router.vuejs.org/guide/migration/#all-navigations-are-now-always-asynchronous
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/library/_itemId/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
<v-app-bar density="compact" flat>
<v-app-bar v-if="!loading" density="compact" flat>
<span class="text-h6 hidden-sm-and-down">
{{ library?.Name }}
</span>
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/pages/playback/video/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ import {
useFullscreen,
useTimeoutFn,
useMagicKeys,
whenever
whenever,
useEventListener
} from '@vueuse/core';
import {
playbackManagerStore,
Expand Down Expand Up @@ -186,6 +187,7 @@ onBeforeUnmount(() => {
});

onMounted(() => {
useEventListener(document, 'keyup', handleKeyUp);
playerElement.isFullscreenMounted = true;
});

Expand All @@ -198,6 +200,35 @@ whenever(keys.j, playbackManager.skipBackward);
whenever(keys.f, fullscreen.toggle);
whenever(keys.m, playbackManager.toggleMute);

/**
* Used to detect media keys (play, pause, ff,rw). these media keys either show up as 'unidentifed' or '' when using useMagicKeys
*/
function handleKeyUp(event: KeyboardEvent): void {
const keyCode = event.keyCode;

// LGTV media key codes. May not be same on other platforms
const pauseKey = 19;
const playKey = 415;
const fastForwardKey = 417;
const rewindKey = 412;

switch (keyCode) {
case playKey:
case pauseKey: {
playbackManager.playPause();
break;
}
case fastForwardKey: {
playbackManager.skipForward();
break;
}
case rewindKey: {
playbackManager.skipBackward();
break;
}
}
}

watch(staticOverlay, (val) => {
if (val) {
timeout.stop();
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/plugins/spatialNav/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import SpatialNavigation from './spatialNavigation';

declare module '@vue/runtime-core' {
export interface ComponentCustomProperties {
$SpatialNavigation: typeof SpatialNavigation;
}
}
Loading