Gabe Farrell 1 year ago
parent df94d26ad6
commit 8e2300bf53

Binary file not shown.

@ -20,6 +20,7 @@
"@fortawesome/free-regular-svg-icons": "^6.7.1",
"@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@jellyfin/sdk": "^0.11.0",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"

@ -1,8 +1,25 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
import { ref } from 'vue'
import JF from './jfapi'
const menuOpen = ref(false)
const logout = () => {
JF.logout()
window.location.href = '/login'
}
</script>
<template>
<div class="primary-nav no-select" v-if="!$route.meta.hideNavbar">
<div class="menu-toggle" @click="menuOpen = !menuOpen">
<font-awesome-icon icon="fa-solid fa-bars" size="xl" />
</div>
<div class="menu" v-show="menuOpen">
<div class="menu-item">Home</div>
<div class="menu-item">Settings</div>
<div class="menu-item" @click="logout">Sign Out</div>
</div>
</div>
<div class="wrapper">
<RouterView />
</div>
@ -13,4 +30,38 @@ import { RouterView } from 'vue-router'
max-width: 90%;
margin-left: auto;
}
.primary-nav {
position: relative;
}
.menu-toggle {
position: absolute;
top: 30px;
left: 30px;
}
.menu-toggle:hover {
cursor: pointer;
color: var(--color-text-faded);
}
.menu {
position: absolute;
top: 25px;
left: 80px;
background-color: var(--color-background-darker);
padding: 1.5em;
display: flex;
align-items: baseline;
flex-direction: column;
gap: 1em;
}
.menu-item {
font-size: 14px;
}
.menu-item:hover {
color: var(--color-text-faded);
cursor: pointer;
}
</style>

@ -42,6 +42,11 @@
--section-gap: 160px;
}
:root {
--hover-animation-delay: 0.3s;
--hover-animation-speed: 0.2s;
}
/* @media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
@ -66,6 +71,7 @@
body {
min-height: 100vh;
min-width: 1600px;
color: var(--color-text);
background: linear-gradient(var(--color-background), var(--color-background-darker) 50%);
transition:

@ -45,6 +45,19 @@ button.primary-button svg {
padding-right: 0.5em;
}
input[type='text'],
input[type='password'] {
padding: 0.6em 1.5em;
background-color: var(--color-text);
outline: none;
border: none;
border-radius: 15px;
color: var(--color-text-dark);
font-family: inherit;
font-weight: 500;
font-size: 16px;
}
span.age-rating {
font-size: 14px;
padding: 0.1em 0.4em;
@ -68,6 +81,13 @@ svg.active.clickable:hover {
cursor: pointer;
}
.clamp {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1; /* number of lines to show */
line-clamp: 1;
-webkit-box-orient: vertical;
}
.clamp-2 {
overflow: hidden;
display: -webkit-box;
@ -75,11 +95,18 @@ svg.active.clickable:hover {
line-clamp: 2;
-webkit-box-orient: vertical;
}
.clamp {
.clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1; /* number of lines to show */
line-clamp: 1;
-webkit-line-clamp: 3; /* number of lines to show */
line-clamp: 3;
-webkit-box-orient: vertical;
}
.clamp-4 {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 4; /* number of lines to show */
line-clamp: 4;
-webkit-box-orient: vertical;
}
.clamp-6 {

@ -0,0 +1,52 @@
<script setup lang="ts">
import MediaScroller from './MediaScroller.vue'
import { ContinueWatching } from '@/feed'
import type { FeedItem } from '@/feed'
const Items = (): FeedItem[] => {
// const feed = []
// jfapi
// .GetNextUp('http://192.168.0.63:30013')
// .then((data) => {
// for (const item of data.Items) {
// const thumbtag = item.ImageTags.Thumb ? item.ImageTags.Thumb : ''
// let summary = ''
// jfapi.GetItem('http://192.168.0.63:30013', '', item.Id).then((item) => {
// summary = item.Overview !== undefined ? item.Overview : ''
// })
// feed.push({
// title: item.SeriesName,
// subtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,
// image: jfapi.ThumbImageUrl('http://192.168.0.63:30013', item.Id, thumbtag),
// summary: summary,
// infoSubtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,
// imdbRating: item.CommunityRating,
// runtime: getDisplayDuration(item.RunTimeTicks),
// date: item.ProductionYear,
// MPAA: item.OfficialRating,
// })
// }
// return feed
// })
// .catch((err) => {
// return err
// })
return ContinueWatching
}
// function getDisplayDuration(ticks: number) {
// const totalMinutes = Math.round(ticks / 600000000) || 1
// const totalHours = Math.floor(totalMinutes / 60)
// const remainderMinutes = totalMinutes % 60
// const result = []
// if (totalHours > 0) {
// result.push(`${totalHours}h`)
// }
// result.push(`${remainderMinutes}m`)
// return result.join(' ')
// }
</script>
<template>
<MediaScroller title="Continue Watching" landscape :items="Items()" />
</template>

@ -1,24 +1,19 @@
<script setup lang="ts">
import ScrollerItemPortrait from './ScrollerItemPortrait.vue'
import ScrollerItemLandscape from './ScrollerItemLandscape.vue'
import type { FeedItem } from '@/feed'
const props = defineProps({
title: {
type: String,
required: true,
},
landscape: Boolean,
items: {
type: [Object],
required: true,
},
})
const props = defineProps<{
title: string
landscape?: boolean
items: FeedItem[]
}>()
</script>
<template>
<div class="wrapper">
<div class="scroller-title">{{ props.title }}</div>
<div class="scroller-content" :style="{ height: `${props.landscape ? 210 : 240}px` }">
<div class="scroller-content no-select" :style="{ height: `${props.landscape ? 210 : 240}px` }">
<div class="scroller-wrapper" v-if="landscape">
<ScrollerItemLandscape
v-for="item in props.items"
@ -46,6 +41,7 @@ const props = defineProps({
:summary="item.summary"
:-m-p-a-a="item.MPAA"
:imdb-rating="item.imdbRating"
:tag="item.tag"
:key="item.title"
/>
</div>

@ -2,7 +2,7 @@
<template>
<div class="media-showcase-hero">
<div class="spacer"></div>
<!-- <div class="spacer"></div> -->
<div class="media-showcase-info">
<div class="media-showcase-logo">
<img src="@/assets/images/vvitch_logo.png" alt="" class="no-select" />
@ -55,7 +55,7 @@
.media-showcase-hero {
display: flex;
justify-content: space-between;
align-items: center;
align-items: end;
}
.media-showcase-logo img {
max-height: 90px;
@ -67,14 +67,14 @@
}
.media-showcase-info {
text-align: center;
padding: 3em 5em;
padding-right: 13em;
padding: 3em 3em;
margin-left: 2%;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.3em;
max-height: 475px;
max-width: 854px;
max-width: 700px;
}
.media-showcase-info-misc {
display: flex;

@ -54,7 +54,7 @@ const props = defineProps({
<div class="info-box-title">{{ props.title }}</div>
<div class="info-box-subtext">{{ props.infoSubtext }}</div>
</div>
<div class="info-box-summary clamp-6">{{ props.summary }}</div>
<div class="info-box-summary clamp-4">{{ props.summary }}</div>
<div class="info-box-misc-info">
<div class="date">{{ props.date }}</div>
<div class="runtime">{{ props.runtime }}</div>
@ -96,17 +96,15 @@ const props = defineProps({
width: 35px;
height: 35px;
cursor: pointer;
z-index: 3;
/* visibility: hidden; */
z-index: 2;
opacity: 0%;
padding: 0.75em;
/* From https://css.glass */
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(6.6px);
-webkit-backdrop-filter: blur(6.6px);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: background 10ms linear 0ms;
}
@ -118,10 +116,10 @@ const props = defineProps({
left: 135px;
opacity: 100%;
transition:
all 0.25s ease 0.5s,
all var(--hover-animation-speed) ease var(--hover-animation-delay),
z-index 1ms,
background 10ms linear 0ms;
z-index: 4;
z-index: 5;
visibility: visible;
}
.scroller-item-img {
@ -137,10 +135,11 @@ const props = defineProps({
transform: scale(1.35) translateX(32px) translateY(17px);
cursor: pointer;
transition:
all 0.25s ease 0.5s,
all var(--hover-animation-speed) ease,
z-index 1ms;
transition-delay: var(--hover-animation-delay);
position: relative;
z-index: 3;
z-index: 4;
}
.scroller-item-title {
text-align: center;
@ -163,12 +162,13 @@ const props = defineProps({
}
.scroller-item-img:hover .info-box {
visibility: visible;
transition-delay: 0.5s;
transform: translateX(324px);
width: 340px;
height: 183px;
z-index: 2;
transition-duration: 0.3s;
z-index: 3;
transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay),
z-index 1ms var(--hover-animation-delay);
}
.info-box-content {
width: 90%;
@ -179,8 +179,9 @@ const props = defineProps({
display: flex;
flex-direction: column;
align-items: baseline;
gap: 2em;
min-height: 80%;
justify-content: space-around;
gap: 1em;
min-height: 100%;
}
.scroller-item:hover .info-box-content {
max-height: 183px;

@ -34,6 +34,9 @@ const props = defineProps({
MPAA: {
type: String,
},
tag: {
type: String,
},
})
</script>
@ -41,6 +44,9 @@ const props = defineProps({
<div class="scroller-item">
<div class="scroller-item-img">
<!-- <div class="scroller-item-poster-wrapper"> -->
<div class="poster-tag" v-if="props.tag !== '' && props.tag !== undefined">
{{ props.tag }}
</div>
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="'/src/assets/images/' + props.image"
@ -98,15 +104,15 @@ const props = defineProps({
width: 35px;
height: 35px;
cursor: pointer;
z-index: 1;
z-index: 2;
opacity: 0%;
padding: 0.75em;
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(6.6px);
-webkit-backdrop-filter: blur(6.6px);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: background 10ms linear 0ms;
}
@ -118,10 +124,10 @@ const props = defineProps({
left: 55px;
opacity: 100%;
transition:
all 0.25s ease 0.5s,
all var(--hover-animation-speed) ease var(--hover-animation-delay),
z-index 1ms,
background 10ms linear 0ms;
z-index: 4;
z-index: 5;
visibility: visible;
}
.scroller-item-img {
@ -137,11 +143,36 @@ const props = defineProps({
transform: scale(1.35) translateX(16px) translateY(23px);
cursor: pointer;
transition:
all 0.25s ease,
all var(--hover-animation-speed) ease,
z-index 1ms;
transition-delay: 0.5s;
transition-delay: var(--hover-animation-delay);
position: relative;
z-index: 3;
z-index: 4;
}
.poster-tag {
position: absolute;
right: 0px;
top: 6px;
z-index: 2;
font-weight: 500;
font-size: 13px;
width: 36px;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 1);
border-radius: 16px 0px 0px 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(2.6px);
-webkit-backdrop-filter: blur(2.6px);
border: 1px solid rgba(255, 255, 255, 0.2);
text-align: center;
transition: all var(--hover-animation-speed) ease 0.2s;
opacity: 100%;
}
.scroller-item-img:hover .poster-tag {
opacity: 0%;
transition: all var(--hover-animation-speed) ease 0.2s;
/* transition-delay: var(--hover-animation-delay); */
/* transform: translateX(10px); */
}
.scroller-item-title {
text-align: center;
@ -164,12 +195,13 @@ const props = defineProps({
}
.scroller-item-img:hover .info-box {
visibility: visible;
transition-delay: 0.5s;
transform: translateX(162px);
width: 360px;
height: 243px;
z-index: 2;
transition-duration: 0.3s;
z-index: 3;
transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay),
z-index 1ms var(--hover-animation-delay);
}
.info-box-content {
width: 90%;
@ -180,8 +212,9 @@ const props = defineProps({
display: flex;
flex-direction: column;
align-items: baseline;
justify-content: space-between;
gap: 2em;
min-height: 80%;
min-height: 95%;
}
.scroller-item:hover .info-box-content {
max-height: 240px;

@ -1,18 +1,39 @@
const ContinueWatching = [
const ContinueWatching: FeedItem[] = [
{
title: 'Bloom Into You',
subtext: 'S1:E7 - Secrets Galore / Sparks',
image: 'biy_ep.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace. This is more content than there was before.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'The Bear',
subtext: 'S3:E9 - Apologies',
image: 'bear_ep.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'BOCCHI THE ROCK!',
subtext: 'S1:E12 - Morning Light Falls on You',
image: 'bocchi_ep.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'Twenty Five Twenty One',
@ -24,19 +45,49 @@ const ContinueWatching = [
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
]
const LatestMovies = [
type FeedItem = {
title: string
subtext: string
image: string
summary: string
infoSubtext: string
imdbRating: string
runtime: string
date: string
MPAA: string
tag: string
}
const LatestMovies: FeedItem[] = [
{
title: 'Alien',
subtext: '1979',
image: 'alien.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Your Name',
subtext: '2017',
image: 'yourname.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'SoulMate',
@ -49,21 +100,46 @@ const LatestMovies = [
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
tag: '',
},
{
title: 'Burning',
subtext: '2018',
image: 'burning.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Better Days',
subtext: '2019',
image: 'betterdays.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'House of Hummingbird',
subtext: '2019',
image: 'hummingbird.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Where We Belong',
@ -76,75 +152,173 @@ A teenager creates a checklist to complete before she studies abroad and realize
runtime: '2h 12m',
date: '2019',
MPAA: 'R',
tag: '',
},
{
title: '1917',
subtext: '2019',
image: '1917.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Life of Brian',
subtext: '1979',
image: 'lob.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Shoplifters',
subtext: '2018',
image: 'shoplifters.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'The Lobster',
subtext: '2016',
image: 'lobster.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
{
title: 'Her',
subtext: '2013',
image: 'her.jpg',
tag: '',
summary:
'The films spans two decades as the story unfolds in a series of flashbacks that begin when Qiyue and Ansheng were just thirteen. The two became inseparable until they met a boy who ended up tearing their lives apart.',
infoSubtext: '七月与安生',
imdbRating: '7.3',
runtime: '1h 50m',
date: '2016',
MPAA: 'PG-13',
},
]
const LatestAnime = [
const LatestAnime: FeedItem[] = [
{
title: 'Re:Zero - Starting Life in Another World',
subtext: 'S3:E6 - Conditions of the Life We Live In',
image: 'rezero.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'Serial Experiments Lain',
subtext: '1998',
image: 'lain.jpg',
tag: '8',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
},
{
title: 'Monster',
subtext: '2004-2005',
image: 'monster.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'Bloom Into You',
subtext: '2018',
image: 'biy.jpg',
tag: '13',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
},
{
title: 'Attack on Titan',
subtext: '2013-2022',
image: 'aot.jpg',
tag: '99+',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
},
{
title: "Frieren: Beyond Journey's End",
subtext: '2023-2024',
image: 'frieren.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'Lycoris Recoil',
subtext: '2022',
image: 'lycoris.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
{
title: 'Tengoku Daimakyo',
subtext: '2023 - Present',
image: 'tengoku.jpg',
summary: `Hee-do takes one giant leap closer to her dream. She's eager to tell Yi-jin the good news — but realizes he's disappeared without a trace.`,
infoSubtext: 'S1:E5 - Episode 5',
imdbRating: '8.7',
runtime: '1h 16m',
date: '2022',
MPAA: 'TV-14',
tag: '',
},
]
export type { FeedItem }
export { ContinueWatching, LatestMovies, LatestAnime }

@ -0,0 +1,181 @@
import { Jellyfin } from '@jellyfin/sdk'
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'
function detectBrowser() {
const userAgent = navigator.userAgent
if (userAgent.indexOf('Edg') > -1) {
return 'Microsoft Edge'
} else if (userAgent.indexOf('Chrome') > -1) {
return 'Chrome'
} else if (userAgent.indexOf('Firefox') > -1) {
return 'Firefox'
} else if (userAgent.indexOf('Safari') > -1) {
return 'Safari'
} else if (userAgent.indexOf('Opera') > -1) {
return 'Opera'
} else if (userAgent.indexOf('Trident') > -1 || userAgent.indexOf('MSIE') > -1) {
return 'Internet Explorer'
}
return 'Unknown'
}
const jellyfin = new Jellyfin({
clientInfo: {
name: 'Featherfin',
version: '0.0.1',
},
deviceInfo: {
name: detectBrowser(),
id: 'abc123',
},
})
const JF = jellyfin.createApi('http://192.168.0.63:30013')
export const Libraries = async () => {
const libs = await getLibraryApi(JF).getMediaFolders()
console.log(libs.data)
return libs.data
}
export default JF
// import { useDataStore } from '@/stores/data'
// type Endpoint = {
// resource: string
// method: string
// }
// type View = {
// Id: string
// Name: string
// CollectionType: string
// ParentId: string
// }
// type NextUp = {
// Items: [Item]
// }
// type Item = {
// Name: string
// Id: string
// Type: string
// OfficialRating?: string
// ImageTags: ImageTags
// CommunityRating?: number
// ProductionYear?: number
// IndexNumber?: number
// ParentIndexNumber?: number
// SeriesName?: string
// Overview?: string
// RunTimeTicks: number
// }
// type ImageTags = {
// Primary?: string
// Thumb?: string
// }
// class JFApi {
// request<T>(host: string, endpoint: Endpoint) {
// return fetch(`${host}${endpoint.resource}`, {
// method: endpoint?.method,
// })
// .then(async (response) => {
// const data = await response.json()
// return data as T
// })
// .catch((error) => {
// return error
// })
// }
// LoadViews(host: string, userid: string) {
// const { setViews } = useDataStore()
// return this.request(host, {
// resource: `/UserViews?userId=${userid}`,
// method: 'GET',
// })
// .then((data) => {
// setViews(data)
// return data
// })
// .catch((err) => {
// return err
// })
// }
// GetLatest(host: string, view: View): Promise<[Item]> {
// return this.request(host, {
// resource: `/Latest&ParentId=${view.Id}`,
// method: 'GET',
// })
// .then((data) => {
// return data
// })
// .catch((err) => {
// return err
// })
// }
// GetNextUp(host: string): Promise<NextUp> {
// return this.request(host, {
// resource: `/Shows/NextUp?Limit=24&Fields=PrimaryImageAspectRatio%2CDateCreated%2CPath%2CMediaSourceCount&UserId=f7967316db8e46b4960690d0df2c0163&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CBanner%2CThumb&EnableTotalRecordCount=false&DisableFirstEpisode=false&NextUpDateCutoff=2023-12-03T21%3A55%3A33.690Z&EnableResumable=false&EnableRewatching=false`,
// method: 'GET',
// })
// .then((data) => {
// return data
// })
// .catch((err) => {
// return err
// })
// }
// GetItem(host: string, userid: string, itemId: string): Promise<Item> {
// return this.request(host, {
// resource: `/User/${userid}/Items/${itemId}`,
// method: 'GET',
// })
// .then((data) => {
// return data
// })
// .catch((err) => {
// return err
// })
// }
// ThumbImageUrl(host: string, itemId: string, imgTag: string) {
// return `${host}/Items/${itemId}/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag=${imgTag}`
// }
// PrimaryImageUrl(host: string, itemId: string, imgTag: string) {
// return `${host}/Items/${itemId}/Images/Primary?fillHeight=446&fillWidth=297&quality=96&tag=${imgTag}`
// }
// Login(host: string, username: string, password: string) {
// return fetch(`${host}/Users/authenticatebyname`, {
// method: 'POST',
// body: JSON.stringify({ Username: username, Pw: password }),
// headers: {
// 'Content-Type': 'application/json',
// Authorization:
// 'MediaBrowser Client="Jellyfin Web", Device="Chrome", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEyNC4wLjAuMCBTYWZhcmkvNTM3LjM2fDE3MTQ1MzI1OTIxODU1", Version="10.10.3"',
// },
// })
// .then((data) => data.json())
// .then((data) => console.log(data))
// }
// }
// export default new JFApi()
// /*
// - /UserViews?userId=XYZ - returns [Library]
// - /Latest?...&ParentId=Library.Id - returns latest in library
// - /Resume?...&MediaTypes=Video - returns unfinished playback
// - /NextUp?... - returns [Episode]
// - /Users/authenticatebyname body:{Username: "xyz", Pw: "xyz123"} returns: AccessToken, User, SessionInfo
// Authentication is done by appending 'Token="MyTokenXYZ123"' to Authorization header
// - /Users/User.Id - returns: User
// - /Users/User.Id/Items?SortBy=DateCreated%2CSortName%2CProductionYear&SortOrder=Descending&IncludeItemTypes=Movie&Recursive=true&Fields=PrimaryImageAspectRatio%2CMediaSourceCount&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CBanner%2CThumb&StartIndex=0&ParentId=f137a2dd21bbc1b99aa5c0f6bf02a805&Limit=100 - returns [Item<Movie>]
// - /Users/User.Id/Items/Item.Id - FullItem
// - /Items/Item.Id/Similar?userId=User.Id&limit=12&fields=PrimaryImageAspectRatio - returns similar items
// - /Items/Item.Id/Images/Primary?fillHeight=713&fillWidth=500&quality=96&tag=Item.ImageTags.Primary - returns Primary Image
// - /.../Images/Thumb?...&tag=Episode.ParentThumbImageTag
// */

@ -10,12 +10,12 @@ const router = createRouter({
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
path: '/login',
name: 'login',
component: () => import(/* webpackChunkName: 'login' */ '@/views/LoginView.vue'),
meta: {
hideNavbar: true,
},
},
],
})

@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

@ -0,0 +1,29 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
type UserData = {
host?: string
id?: string
name?: string
views?: [View]
}
type View = {
name: string
mediaType: string
id: string
}
export const useDataStore = defineStore('data', () => {
const data = ref<UserData>({})
function setServerHost(host: string) {
data.value.host = host
}
function setViews(views: [View]) {
data.value.views = views
}
function setUser(id: string, name: string) {
data.value.id = id
data.value.name = name
}
return { data, setServerHost, setViews, setUser }
})

@ -1,15 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

@ -1,7 +1,11 @@
<script setup lang="ts">
import MediaShowcase from '@/components/MediaShowcase.vue'
import MediaScroller from '@/components/MediaScroller.vue'
import { ContinueWatching, LatestAnime, LatestMovies } from '@/feed'
import { LatestAnime, LatestMovies } from '@/feed'
import ContinueWatching from '@/components/ContinueWatching.vue'
import { Libraries } from '@/jfapi'
console.log('Libraries =>', Libraries())
</script>
<template>
@ -14,7 +18,7 @@ import { ContinueWatching, LatestAnime, LatestMovies } from '@/feed'
<div class="home-nav-item">Movies</div>
<div class="home-nav-item">Shows</div>
</div>
<MediaScroller title="Continue Watching" landscape :items="ContinueWatching" />
<ContinueWatching />
<MediaScroller title="Latest Movies" :items="LatestMovies" />
<MediaScroller title="Latest Anime" :items="LatestAnime" />
</main>

@ -0,0 +1,49 @@
<script setup lang="ts">
import JF from '@/jfapi'
import { ref } from 'vue'
const username = ref('')
const password = ref('')
const login = () => {
JF.authenticateUserByName(username.value, password.value).then(() => {
window.location.href = '/'
})
}
</script>
<template>
<div class="login-wrapper">
<div class="form-item">
<div class="label">Username</div>
<input type="text" v-model="username" />
</div>
<div class="form-item">
<div class="label">Password</div>
<input type="password" v-model="password" />
</div>
<div class="form-item">
<button class="primary-button" @click="login">Submit</button>
</div>
</div>
</template>
<style scoped>
.form-item {
width: 100%;
}
.login-wrapper {
width: 300px;
display: flex;
flex-direction: column;
align-items: baseline;
margin: 0 auto;
margin-top: 30vh;
gap: 1em;
}
input {
width: 100%;
}
.label {
padding-left: 1em;
}
</style>
Loading…
Cancel
Save