Compare commits

..

No commits in common. '1cd1584bb3efd4d5d74a020205ef4b8a902f41ad' and '8e2300bf533b8c2c9a6791c30825856ce08028d7' have entirely different histories.

Binary file not shown.

@ -1,25 +1,21 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { RouterView } from 'vue-router'
import { ref } from 'vue'
import jfapi from './jfapi'
import JF from './jfapi'
const menuOpen = ref(false)
const logout = () => {
jfapi.Logout().then(() => {
localStorage.removeItem('jf_views')
localStorage.removeItem('jf_userid')
localStorage.removeItem('jf_host')
JF.logout()
window.location.href = '/login'
})
}
</script>
<template>
<div class="primary-nav no-select" v-if="!$route.meta.isLoginPage">
<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">
<RouterLink class="menu-item" to="/home">Home</RouterLink>
<div class="menu-item">Home</div>
<div class="menu-item">Settings</div>
<div class="menu-item" @click="logout">Sign Out</div>
</div>
@ -31,7 +27,8 @@ const logout = () => {
<style scoped>
.wrapper {
margin: 0 auto;
max-width: 90%;
margin-left: auto;
}
.primary-nav {
@ -58,17 +55,13 @@ const logout = () => {
align-items: baseline;
flex-direction: column;
gap: 1em;
z-index: 100; /* always on top */
}
.menu-item {
font-size: 14px;
}
.menu-item:hover {
color: var(--color-text-faded);
cursor: pointer;
text-decoration: none;
}
.footer {
height: 100px;
}
</style>

@ -73,7 +73,7 @@ body {
min-height: 100vh;
min-width: 1600px;
color: var(--color-text);
background-color: var(--color-background-darker);
background: linear-gradient(var(--color-background), var(--color-background-darker) 50%);
transition:
color 0.5s,
background-color 0.5s;

@ -20,25 +20,8 @@
} */
}
select {
padding: 0.5em 1em;
border-radius: 15px;
background: rgba(0, 0, 0, 0.1);
color: var(--color-text);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(1px);
border: 1px solid rgba(255, 255, 255, 0.3);
outline: none;
}
option {
background-color: var(--color-background-darker);
}
button.primary-button {
padding: 0.8em 1.75em;
padding: 0.6em 1.5em;
background-color: var(--color-text);
outline: none;
border: none;
@ -98,15 +81,6 @@ svg.active.clickable:hover {
cursor: pointer;
}
a {
text-decoration: none;
color: var(--color-text);
}
a:hover {
text-decoration: underline;
}
.clamp {
overflow: hidden;
display: -webkit-box;

@ -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>

@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

@ -1,27 +0,0 @@
<script setup lang="ts">
import type { View } from '@/jfapi'
import NextUp from './NextUp.vue'
import LatestMedia from './LatestMedia.vue'
import { onBeforeMount, ref } from 'vue'
// const props = defineProps<{
// views: View[]
// }>()
const views = ref<View[]>()
const viewstring = ref<string | null>('')
views.value = undefined
onBeforeMount(() => {
viewstring.value = localStorage.getItem('jf_views')
views.value = JSON.parse(viewstring.value ? viewstring.value : '')
})
console.log('HomeMedia =>', views.value)
</script>
<template>
<NextUp />
<!-- <MediaScroller title="Latest Movies" :items="LatestMovies" />
<MediaScroller title="Latest Anime" :items="LatestAnime" /> -->
<LatestMedia v-for="view of views" :view="view" :key="view.Id" />
</template>
<style scoped></style>

@ -1,63 +0,0 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import MediaScroller from './MediaScroller.vue'
import type { FeedItem } from '@/feed'
import type { View } from '@/jfapi'
import { getDisplayDuration, getImageLink } from './utils'
const props = defineProps<{
view: View
}>()
const Items = async (): Promise<FeedItem[]> => {
return new Promise(async (resolve) => {
const feed: FeedItem[] = []
const userid = localStorage.getItem('jf_userid')
const data = await jfapi.GetLatest(userid ? userid : '', props.view)
if (data !== null) {
console.log(data)
for (const item of data) {
const summary = ''
if (userid === null) {
window.location.href = '/login'
}
// const fullitem = await jfapi.GetItem(userid, item.Id)
// if (fullitem === null) {
// continue
// } else {
// summary = fullitem.Overview
// }
feed.push({
title: item.SeriesName || item.Name,
subtext: item.SeriesName
? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
: String(item.ProductionYear),
image: getImageLink(item),
summary: summary,
// infoSubtext: item.SeriesName
// ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
// : fullitem.OriginalTitle != item.Name
// ? fullitem.OriginalTitle
// : '',
infoSubtext: '',
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
date: String(item.ProductionYear),
MPAA: item.OfficialRating,
tag:
item.UserData?.UnplayedItemCount !== undefined
? String(item.UserData.UnplayedItemCount)
: '',
itemId: item.Id,
})
}
console.log('LatestMedia.vue =>', feed)
resolve(feed)
}
})
}
</script>
<template>
<MediaScroller :title="`Latest ${view?.Name}`" :feed-items="Items()" />
</template>

@ -1,110 +0,0 @@
<script setup lang="ts">
import type { FeedItem } from '@/feed'
import jfapi, { type View } from '@/jfapi'
import { getImageLink, getDisplayDuration } from './utils'
import { onBeforeMount, ref } from 'vue'
import { useRoute } from 'vue-router'
import ScrollerItemPortrait from './ScrollerItemPortrait.vue'
const route = useRoute()
const viewId = String(route.params.id)
console.log(viewId)
const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
return new Promise(async (resolve) => {
const feed: FeedItem[] = []
const userid = localStorage.getItem('jf_userid')
const data = await jfapi.GetItemsInView(userid ? userid : '', viewId, type)
if (data !== null) {
console.log(data)
for (const item of data.Items) {
const summary = ''
if (userid === null) {
window.location.href = '/login'
}
feed.push({
title: item.SeriesName || item.Name,
subtext: item.SeriesName
? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
: String(item.ProductionYear),
image: getImageLink(item),
summary: summary,
infoSubtext: '',
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
date: String(item.ProductionYear),
MPAA: item.OfficialRating,
tag:
item.UserData?.UnplayedItemCount !== undefined
? String(item.UserData.UnplayedItemCount)
: '',
itemId: item.Id,
})
}
console.log('MediaGrid.vue =>', feed)
resolve(feed)
}
})
}
const items = ref<FeedItem[]>()
const views = ref<View[]>()
const viewstring = ref<string | null>('')
views.value = undefined
onBeforeMount(async () => {
viewstring.value = localStorage.getItem('jf_views')
views.value = JSON.parse(viewstring.value ? viewstring.value : '')
let viewType = ''
for (const view of views.value) {
if (view.Id === viewId) {
if (view.CollectionType.toLowerCase() === 'movies') {
viewType = 'Movie'
} else if (view.CollectionType.toLowerCase() === 'tvshows') {
viewType = 'Series'
}
}
}
console.log('ItemType =>', viewType)
const data = ref<FeedItem[]>()
console.log(viewType)
data.value = await Items(viewId, viewType)
items.value = data.value
})
</script>
<template>
<div class="media-grid">
<div class="grid-wrapper">
<ScrollerItemPortrait
v-for="item in items"
:title="item.title"
:subtext="item.subtext"
:image="item.image"
:info-subtext="item.infoSubtext"
:date="item.date"
:runtime="item.runtime"
:summary="item.summary"
:-m-p-a-a="item.MPAA"
:imdb-rating="item.imdbRating"
:key="item.title"
:itemId="item.itemId"
:tag="item.tag"
class="scroller-item"
/>
</div>
</div>
</template>
<style scoped>
.media-grid {
width: 80%;
margin: auto;
margin-top: 3em;
}
.grid-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1em;
}
</style>

@ -1,21 +0,0 @@
<template>
<div class="info-misc">
<div class="misc-info-year">2019</div>
<div class="misc-info-runtime">2h 12m</div>
<div class="misc-info imdb-rating">
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" /> 7.6
</div>
<div class="misc-info-age-rating"><span class="age-rating">R</span></div>
<div class="media-showcase-end-time">Ends at 6:23pm</div>
</div>
</template>
<style scoped>
.info-misc {
display: flex;
justify-content: space-around;
gap: 3em;
color: var(--color-text);
font-size: 18px;
}
</style>

@ -2,74 +2,21 @@
import ScrollerItemPortrait from './ScrollerItemPortrait.vue'
import ScrollerItemLandscape from './ScrollerItemLandscape.vue'
import type { FeedItem } from '@/feed'
import { ref, onBeforeMount } from 'vue'
const props = defineProps<{
title: string
landscape?: boolean
feedItems: Promise<FeedItem[]>
items: FeedItem[]
}>()
const items = ref<FeedItem[]>()
const overflow = ref(false)
const translate = ref(0)
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
const renderSpace = vw * 0.55
onBeforeMount(async () => {
items.value = await props.feedItems
console.log('MediaScroller.vue =>', props.feedItems)
const itemLimit = renderSpace / (props.landscape ? 245 : 125)
console.log(props.title, '=>', itemLimit)
if (items.value.length > itemLimit) {
overflow.value = true
}
})
const translateForward = () => {
disableForward()
if (props.landscape) {
translate.value -= 1008
} else {
translate.value -= 924
}
}
const translateBackward = () => {
if (props.landscape) {
translate.value += 1008
} else {
translate.value += 924
}
}
const disableForward = () => {
const v =
(items.value ? items.value.length : 0) * (props.landscape ? 245 : 125) -
Math.abs(translate.value) <
renderSpace
return v
}
</script>
<template>
<div class="wrapper" v-show="(items ? items.length : 0) > 0">
<div class="scroller-header">
<div class="wrapper">
<div class="scroller-title">{{ props.title }}</div>
<div class="scroller-controls" v-show="overflow">
<button class="translate-button" @click="translateBackward()" :disabled="translate >= 0">
<font-awesome-icon icon="fa-solid fa-play" class="fa-flip-horizontal" size="sm" />
</button>
<button class="translate-button" @click="translateForward()" :disabled="disableForward()">
<font-awesome-icon icon="fa-solid fa-play" size="sm" />
</button>
</div>
</div>
<div
class="scroller-content no-select"
:style="{
height: `${props.landscape ? 210 : 240}px`,
transform: `translateX(${translate}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 items"
v-for="item in props.items"
:title="item.title"
:subtext="item.subtext"
:image="item.image"
@ -79,14 +26,12 @@ const disableForward = () => {
:summary="item.summary"
:-m-p-a-a="item.MPAA"
:imdb-rating="item.imdbRating"
:itemId="item.itemId"
:key="item.title"
class="scroller-item"
/>
</div>
<div class="scroller-wrapper" v-else>
<ScrollerItemPortrait
v-for="item in items"
v-for="item in props.items"
:title="item.title"
:subtext="item.subtext"
:image="item.image"
@ -96,10 +41,8 @@ const disableForward = () => {
:summary="item.summary"
:-m-p-a-a="item.MPAA"
:imdb-rating="item.imdbRating"
:key="item.title"
:itemId="item.itemId"
:tag="item.tag"
class="scroller-item"
:key="item.title"
/>
</div>
</div>
@ -113,41 +56,6 @@ const disableForward = () => {
.scroller-wrapper {
display: flex;
gap: 1em;
}
.scroller-controls svg {
padding: 1em;
margin-right: 2em;
}
.scroller-controls {
margin-right: 10em;
}
.scroller-header {
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
}
.scroller-content {
transition: all 0.3s ease;
}
.translate-button {
border: none;
background: none;
color: var(--color-text);
}
.translate-button:hover {
cursor: pointer;
color: var(--color-text-faded);
}
.translate-button:disabled {
border: none;
background: none;
color: var(--color-text-faded);
}
.translate-button:disabled:hover {
cursor: default;
}
.wrapper {
overflow: hidden;
font-size: 12px;
}
</style>

@ -56,7 +56,6 @@
display: flex;
justify-content: space-between;
align-items: end;
background: linear-gradient(var(--color-background), var(--color-background-darker));
}
.media-showcase-logo img {
max-height: 90px;
@ -69,7 +68,7 @@
.media-showcase-info {
text-align: center;
padding: 3em 3em;
margin-left: 12%;
margin-left: 2%;
display: flex;
flex-direction: column;
align-items: center;

@ -1,75 +0,0 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import MediaScroller from './MediaScroller.vue'
import type { FeedItem } from '@/feed'
import type { Item } from '@/jfapi'
const Items = async (): Promise<FeedItem[]> => {
return new Promise(async (resolve) => {
const feed: FeedItem[] = []
const data = await jfapi.GetNextUp()
if (data !== null) {
for (const item of data.Items) {
const summary = ''
const userid = localStorage.getItem('jf_userid')
if (userid === null) {
window.location.href = '/login'
}
// const fullitem = await jfapi.GetItem(userid ? userid : '', item.Id)
// if (fullitem === null) {
// summary = 'Failed to fetch content.'
// } else {
// summary = fullitem.Overview
// }
feed.push({
title: item.SeriesName !== undefined ? item.SeriesName : '',
subtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,
image: getImageLink(item),
summary: summary,
infoSubtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
date: String(item.ProductionYear),
MPAA: item.OfficialRating,
itemId: item.Id,
tag: '',
})
}
console.log('NextUp.vue =>', feed)
resolve(feed)
}
})
}
function getImageLink(item: Item): string {
if (item.ParentThumbItemId === undefined) {
// using backdrop
return jfapi.BackdropImageUrl(
item.ParentBackdropItemId ? item.ParentBackdropItemId : '',
item.ParentBackdropImageTags ? item.ParentBackdropImageTags[0] : '',
)
} else {
// using thumb
return jfapi.ThumbImageUrl(
item.ParentThumbItemId,
item.ParentThumbImageTag ? item.ParentThumbImageTag : '',
)
}
}
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="Next Up" landscape :feed-items="Items()" />
</template>

@ -1,6 +1,4 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import { ref } from 'vue'
const props = defineProps({
title: {
type: String,
@ -36,35 +34,15 @@ const props = defineProps({
MPAA: {
type: String,
},
itemId: {
type: String,
required: true,
},
})
const overview = ref('')
const seriesId = ref('')
const userid = localStorage.getItem('jf_userid')
jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
if (data === null) {
// error
} else {
overview.value = data.Overview
seriesId.value = String(data.SeriesId)
}
})
</script>
<template>
<div
class="scroller-item"
:style="{
width: `${240}px`,
}"
>
<div class="scroller-item">
<div class="scroller-item-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="props.image"
:src="'/src/assets/images/' + props.image"
alt=""
:style="{
width: `${240}px`,
@ -73,12 +51,10 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
<div class="info-box">
<div class="info-box-content">
<div class="info-box-header">
<RouterLink class="info-box-title clamp" :to="`/item/${seriesId}`">{{
props.title
}}</RouterLink>
<div class="info-box-title">{{ props.title }}</div>
<div class="info-box-subtext">{{ props.infoSubtext }}</div>
</div>
<div class="info-box-summary clamp-4">{{ overview }}</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>
@ -86,8 +62,8 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ props.imdbRating }}
</div>
<div class="mpaa" v-show="props.MPAA !== undefined">
<span class="age-rating sm"> {{ props.MPAA }}</span>
<div class="mpaa">
<span class="age-rating sm">{{ props.MPAA }}</span>
</div>
<div class="ends-at">Ends at 6:23pm</div>
</div>
@ -108,7 +84,6 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
display: flex;
flex-direction: column;
align-items: center;
font-size: 12px;
}
/* .scroller-item:hover {
cursor: pointer;
@ -208,18 +183,13 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
gap: 1em;
min-height: 100%;
}
.scroller-item-img:hover .info-box-content {
.scroller-item:hover .info-box-content {
max-height: 183px;
transition-delay: 0.05s;
}
.info-box-title {
font-weight: 600;
font-size: 18px;
color: var(--color-text);
text-decoration: none;
}
.info-box-title:hover {
text-decoration: underline;
}
.info-box-subtext {
color: var(--color-text-faded);

@ -1,8 +1,4 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps({
title: {
type: String,
@ -41,58 +37,33 @@ const props = defineProps({
tag: {
type: String,
},
itemId: {
type: String,
required: true,
},
})
const overview = ref('')
const infosubtxt = ref('')
const userid = localStorage.getItem('jf_userid')
jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
if (data === null) {
// error
} else {
overview.value = data.Overview
infosubtxt.value =
data.Type === 'Episode'
? `S${data.ParentIndexNumber}:E${data.IndexNumber} - ${data.Name}`
: data.OriginalTitle != data.Name
? data.OriginalTitle
: ''
}
})
</script>
<template>
<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">
<div class="text-tag" v-if="props.tag !== '0'">{{ props.tag }}</div>
<div class="icon-tag" v-else>
<font-awesome-icon icon="fa-solid fa-check" size="md" class="clickable" />
</div>
{{ props.tag }}
</div>
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="props.image"
:src="'/src/assets/images/' + props.image"
alt=""
:style="{
width: `${120}px`,
height: `${120 * (3 / 2)}px`,
}"
/>
<!-- </div> -->
<div class="info-box">
<div class="info-box-content">
<div class="info-box-header">
<RouterLink class="info-box-title clamp" :to="`/item/${props.itemId}`">{{
props.title
}}</RouterLink>
<div class="info-box-subtext" v-show="infosubtxt !== ''">
{{ infosubtxt }}
</div>
<div class="info-box-title">{{ props.title }}</div>
<div class="info-box-subtext">{{ props.infoSubtext }}</div>
</div>
<div class="info-box-summary clamp-6">{{ overview }}</div>
<div class="info-box-summary clamp-6">{{ props.summary }}</div>
<div class="info-box-misc-info">
<div class="date">{{ props.date }}</div>
<div class="runtime">{{ props.runtime }}</div>
@ -100,7 +71,7 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ props.imdbRating }}
</div>
<div class="mpaa" v-show="props.MPAA !== undefined">
<div class="mpaa">
<span class="age-rating sm">{{ props.MPAA }}</span>
</div>
<div class="ends-at">Ends at 6:23pm</div>
@ -122,8 +93,10 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
display: flex;
flex-direction: column;
align-items: center;
font-size: 12px;
}
/* .scroller-item:hover {
cursor: pointer;
} */
.play-icon {
position: absolute;
top: 75px;
@ -191,7 +164,6 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
backdrop-filter: blur(2.6px);
-webkit-backdrop-filter: blur(2.6px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
text-align: center;
transition: all var(--hover-animation-speed) ease 0.2s;
opacity: 100%;
@ -244,18 +216,13 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
gap: 2em;
min-height: 95%;
}
.scroller-item-img:hover .info-box-content {
.scroller-item:hover .info-box-content {
max-height: 240px;
transition-delay: 0.05s;
}
.info-box-title {
font-weight: 600;
font-size: 18px;
color: var(--color-text);
text-decoration: none;
}
.info-box-title:hover {
text-decoration: underline;
}
.info-box-subtext {
color: var(--color-text-faded);

@ -0,0 +1,90 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
and
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
>Cypress Component Testing</a
>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a>
and follow the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

@ -1,29 +0,0 @@
import jfapi from '@/jfapi'
import type { Item } from '@/jfapi'
function getImageLink(item: Item): string {
if (item.SeriesId === undefined) {
// using movie primary
return jfapi.PrimaryImageUrl(item.Id, item.ImageTags.Primary ? item.ImageTags.Primary : '')
} else {
// using series primary
return jfapi.PrimaryImageUrl(
item.SeriesId,
item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag[0] : '',
)
}
}
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(' ')
}
export { getImageLink, getDisplayDuration }

@ -1,53 +1,53 @@
// 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',
// subtext: 'S1:E5 - Episode 5',
// image: '2521_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: '',
// },
// ]
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',
subtext: 'S1:E5 - Episode 5',
image: '2521_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: '',
},
]
type FeedItem = {
title: string
@ -60,266 +60,265 @@ type FeedItem = {
date: string
MPAA: string
tag: string
itemId: 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',
// subtext: '2016',
// image: 'soulmate.jpg',
// 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',
// 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',
// subtext: '2019',
// image: 'wwb.jpg',
// summary: `Will there be someone like you where i'm going?\n
// A teenager creates a checklist to complete before she studies abroad and realizes that her toughest task is leaving behind her best friend.`,
// infoSubtext: 'ที่ตรงนั้น มีฉันหรือเปล่า',
// imdbRating: '7.6',
// 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 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',
subtext: '2016',
image: 'soulmate.jpg',
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',
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',
subtext: '2019',
image: 'wwb.jpg',
summary: `Will there be someone like you where i'm going?\n
A teenager creates a checklist to complete before she studies abroad and realizes that her toughest task is leaving behind her best friend.`,
infoSubtext: 'ที่ตรงนั้น มีฉันหรือเปล่า',
imdbRating: '7.6',
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: 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: '',
// },
// ]
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 }
export { ContinueWatching, LatestMovies, LatestAnime }

@ -1,3 +1,6 @@
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) {
@ -17,229 +20,162 @@ function detectBrowser() {
return 'Unknown'
}
type Endpoint = {
resource: string
method: string
}
type View = {
Id: string
Name: string
CollectionType: string
ParentId: string
}
type ViewList = {
Items: View[]
}
type NextUp = {
Items: Item[]
}
type Item = {
Name: string
OriginalTitle: string
Id: string
ParentId?: string
Type: string
OfficialRating: string
ImageTags: ImageTags
CommunityRating: number
ProductionYear: number
IndexNumber?: number
ParentIndexNumber?: number
SeriesName?: string
SeriesId?: string
Overview: string
RunTimeTicks: number
BackdropImageTags: string[]
ParentThumbItemId?: string
ParentThumbImageTag?: string
ParentBackdropItemId?: string
ParentBackdropImageTags?: string[]
SeriesPrimaryImageTag?: string
UserData?: UserData
MediaStreams: MediaStream[]
Taglines: string[]
Genres: string[]
Studios: Studio[]
People: Person[]
}
type Person = {
Name: string
Id: string
Role: string
Type: string
PrimaryImageTag?: string
}
type Studio = {
Name: string
Id: string
}
type MediaStream = {
Language: string
Type: string
DisplayTitle: string
IsDefault: boolean
IsExternal: boolean
IsForced: boolean
}
type ItemList = {
Items: Item[]
StartIndex: number
TotalRecordCount: number
}
type UserData = {
UnplayedItemCount: number
}
type ImageTags = {
Primary?: string
Thumb?: string
Logo?: string
}
type User = {
Name: string
Host: string
Id: string
Views?: View[]
}
export type { User, View, Item, ViewList, ItemList, MediaStream }
class JFApi {
request<T>(endpoint: Endpoint): Promise<T | null> {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return new Promise((resolve) => {
fetch(`${host}${endpoint.resource}`, {
method: endpoint?.method,
headers: {
Authorization: `MediaBrowser Client="Featherfin", Device="${detectBrowser()}", DeviceId="abc123", Version="v0.0.1", Token="${localStorage.getItem('jfauth_token')}"`,
const jellyfin = new Jellyfin({
clientInfo: {
name: 'Featherfin',
version: '0.0.1',
},
})
.then(async (response) => {
const data = await response.json()
resolve(data as T)
})
.catch((error) => {
console.log(error)
resolve(null)
})
})
}
LoadViews(userid: string): Promise<ViewList | null> {
return this.request({
resource: `/UserViews?userId=${userid}`,
method: 'GET',
})
}
GetLatest(userid: string, view: View): Promise<Item[] | null> {
return this.request({
resource: `/Users/${userid}/Items/Latest?Limit=16&Fields=PrimaryImageAspectRatio%2CPath&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb&ParentId=${view.Id}`,
method: 'GET',
})
}
GetNextUp(): Promise<NextUp | null> {
return this.request({
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',
})
}
GetItem(userid: string, itemId: string): Promise<Item | null> {
return this.request({
resource: `/Items/${itemId}?userId=${userid}`,
method: 'GET',
})
}
GetItemsInView(userid: string, viewId: string, type: string) {
return this.request<ItemList>({
resource: `/Users/${userid}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=${type}&Recursive=true&Fields=PrimaryImageAspectRatio&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CBanner%2CThumb&StartIndex=0&Limit=100&ParentId=${viewId}`,
method: 'GET',
})
}
ThumbImageUrl(itemId: string, imgTag: string) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return `${host}/Items/${itemId}/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag=${imgTag}`
}
BackdropImageUrl(itemId: string, imgTag: string) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return `${host}/Items/${itemId}/Images/Backdrop?fillHeight=267&fillWidth=474&quality=96&tag=${imgTag}`
}
FullBackdropImageUrl(itemId: string, imgTag: string, imgIndex: number) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return `${host}/Items/${itemId}/Images/Backdrop/${imgIndex}?tag=${imgTag}`
}
PrimaryImageUrl(itemId: string, imgTag: string) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return `${host}/Items/${itemId}/Images/Primary?fillHeight=446&fillWidth=297&quality=96&tag=${imgTag}`
}
LogoImageUrl(itemId: string, imgTag: string) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
return `${host}/Items/${itemId}/Images/Logo?tag=${imgTag}`
}
Login(host: string, username: string, password: string): Promise<User | null> {
return new Promise((resolve) => {
fetch(`${host}/Users/authenticatebyname`, {
method: 'POST',
body: JSON.stringify({ Username: username, Pw: password }),
headers: {
'Content-Type': 'application/json',
Authorization: `MediaBrowser Client="Featherfin", Device="${detectBrowser()}", DeviceId="abc123", Version="v0.0.1", Token="${localStorage.getItem('jfauth_token')}"`,
deviceInfo: {
name: detectBrowser(),
id: 'abc123',
},
})
.then((r) => r.json().then((data) => ({ status: r.status, body: data })))
.then((data) => {
console.log(data)
if (data.status !== 200) {
// bad
resolve(null)
} else {
localStorage.setItem('jfauth_token', data.body.AccessToken)
resolve({
Name: data.body.User.Name,
Id: data.body.User.Id,
Host: 'http://192.168.0.63:30013',
})
}
})
})
}
Logout() {
return this.request({
resource: '/Sessions/Logout',
method: 'POST',
})
.then((data) => {
localStorage.removeItem('jfauth_token')
return data
})
.catch((err) => {
return err
})
}
}
})
export default new JFApi()
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
}
/*
- /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
- /Sessions/Logout - logout
*/
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
// */

@ -12,12 +12,23 @@ import { library } from '@fortawesome/fontawesome-svg-core'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
fetch('http://192.168.0.63:30013/Branding/Configuration')
.then((r) => r.json())
.then((r) => console.log(r))
/* import specific icons */
import { fas } from '@fortawesome/free-solid-svg-icons'
import { far } from '@fortawesome/free-regular-svg-icons'
import {
faCheck,
faHeart,
faPlay,
faBars,
faEllipsis,
faStar,
faCircle,
} from '@fortawesome/free-solid-svg-icons'
/* add icons to the library */
library.add(fas, far)
library.add(faCheck, faHeart, faPlay, faBars, faEllipsis, faStar, faCircle)
const app = createApp(App)

@ -1,44 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import BrowseView from '@/views/BrowseView.vue'
import HomeMedia from '@/components/HomeMedia.vue'
import MediaGrid from '@/components/MediaGrid.vue'
import ItemView from '@/views/ItemView.vue'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'browse',
component: BrowseView,
redirect: '/home',
children: [
{
path: 'home',
component: HomeMedia,
},
{
path: 'view/:id',
component: MediaGrid,
},
],
},
{
path: '/item',
redirect: '/home',
children: [
{
path: ':id',
component: ItemView,
},
],
name: 'home',
component: HomeView,
},
{
path: '/login',
name: 'login',
component: () => import(/* webpackChunkName: 'login' */ '@/views/LoginView.vue'),
meta: {
isLoginPage: true,
hideNavbar: true,
},
},
],

@ -1,22 +1,29 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { User, View } from '@/jfapi'
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<User>({
Name: '',
Host: '',
Id: '',
})
// function setServerHost(host: string) {
// data.value.Host = host
// }
function setViews(views: View[]) {
data.value.Views = views
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
}
// function setUser(id: string, name: string) {
// data.value.Id = id
// data.value.Name = name
// }
return { setViews }
return { data, setServerHost, setViews, setUser }
})

@ -1,89 +0,0 @@
<script setup lang="ts">
import MediaShowcase from '@/components/MediaShowcase.vue'
import type { View, ViewList } from '@/jfapi'
import { onBeforeMount, ref } from 'vue'
import jfapi from '@/jfapi'
import { useRoute } from 'vue-router'
const views = ref<View[]>()
onBeforeMount(async () => {
const viewlist = ref<ViewList | null>()
const userid = localStorage.getItem('jf_userid')
const host = localStorage.getItem('jf_host')
console.log('Host =>', host)
viewlist.value = await jfapi.LoadViews(userid ? userid : '')
if (window.location.pathname.toLowerCase() !== '/login') {
if (host === null) {
window.location.href = '/login'
} else if (viewlist.value === null) {
window.location.href = '/login'
}
}
localStorage.setItem('jf_views', JSON.stringify(viewlist.value ? viewlist.value.Items : ''))
views.value = viewlist.value ? viewlist.value.Items : undefined
console.log(views.value)
})
const route = useRoute()
const homeIsActive = () => {
return route.path === '/home'
}
const viewIsActive = (id: string) => {
return route.path.split('/')[2] === id
}
</script>
<template>
<main>
<MediaShowcase />
<div class="home-nav">
<RouterLink to="/home" class="home-nav-item" :class="homeIsActive() ? 'active' : ''"
>Home</RouterLink
>
<RouterLink
class="home-nav-item"
v-for="view in views"
:to="`/view/${view.Id}`"
:key="view.Id"
:class="viewIsActive(view.Id) ? 'active' : ''"
>{{ view.Name }}</RouterLink
>
</div>
<div class="media">
<RouterView v-slot="{ Component, route }"
><component :is="Component" :key="route.path"
/></RouterView>
</div>
</main>
</template>
<style scoped>
a {
color: var(--color-text);
text-decoration: none;
}
.home-nav {
display: flex;
justify-content: center;
margin-top: 1.5em;
gap: 3em;
align-items: center;
}
.home-nav-item:hover {
cursor: pointer;
color: var(--color-text-faded);
}
.home-nav-item.active {
padding: 0.4em 1.5em;
background-color: var(--color-text);
border-radius: 15px;
color: var(--color-text-dark);
font-weight: 600;
}
.home-nav-item.active:hover {
background-color: var(--color-text-faded);
}
.media {
margin: 0 10%;
}
</style>

@ -1,27 +1,11 @@
<script setup lang="ts">
import MediaShowcase from '@/components/MediaShowcase.vue'
import type { View, ViewList } from '@/jfapi'
import { onBeforeMount, ref } from 'vue'
import jfapi from '@/jfapi'
import MediaScroller from '@/components/MediaScroller.vue'
import { LatestAnime, LatestMovies } from '@/feed'
import ContinueWatching from '@/components/ContinueWatching.vue'
import { Libraries } from '@/jfapi'
const views = ref<View[]>()
onBeforeMount(async () => {
const viewlist = ref<ViewList | null>()
const userid = localStorage.getItem('jf_userid')
const host = localStorage.getItem('jf_host')
console.log('Host =>', host)
viewlist.value = await jfapi.LoadViews(userid ? userid : '')
if (window.location.pathname.toLowerCase() !== '/login') {
if (host === null) {
window.location.href = '/login'
} else if (viewlist.value === null) {
window.location.href = '/login'
}
}
views.value = viewlist.value ? viewlist.value.Items : undefined
console.log(views.value)
localStorage.setItem('jf_views', JSON.stringify(views.value ? views.value : ''))
})
console.log('Libraries =>', Libraries())
</script>
<template>
@ -29,11 +13,14 @@ onBeforeMount(async () => {
<MediaShowcase />
<div class="home-nav">
<div class="home-nav-item active">Home</div>
<div class="home-nav-item" v-for="view in views" :key="view.Id">{{ view.Name }}</div>
</div>
<div class="media">
<RouterView />
<div class="home-nav-item">4k Movies</div>
<div class="home-nav-item">Anime</div>
<div class="home-nav-item">Movies</div>
<div class="home-nav-item">Shows</div>
</div>
<ContinueWatching />
<MediaScroller title="Latest Movies" :items="LatestMovies" />
<MediaScroller title="Latest Anime" :items="LatestAnime" />
</main>
</template>
@ -41,6 +28,7 @@ onBeforeMount(async () => {
.home-nav {
display: flex;
justify-content: center;
margin-right: 11.5%;
margin-top: 1.5em;
gap: 3em;
align-items: center;
@ -59,7 +47,4 @@ onBeforeMount(async () => {
.home-nav-item.active:hover {
background-color: var(--color-text-faded);
}
.media {
margin: 0 10%;
}
</style>

@ -1,233 +0,0 @@
<script setup lang="ts">
import MediaInfo from '@/components/MediaInfo.vue'
import jfapi, { type Item } from '@/jfapi'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const itemId = String(route.params.id)
const item = ref<Item>()
const directors = ref<string[]>([])
const writers = ref<string[]>([])
// const studios = ref<string[]>()
const numAudioStreams = ref(0)
const userid = localStorage.getItem('jf_userid')
jfapi.GetItem(userid ? userid : '', itemId).then((data) => {
if (data === null) {
// error
} else {
item.value = data
}
if (item.value !== undefined) {
for (const stream of item.value.MediaStreams) {
if (stream.Type.toLowerCase() === 'audio') {
numAudioStreams.value++
}
}
for (const person of item.value.People) {
if (person.Type.toLowerCase() === 'director') {
directors.value.push(person.Name)
} else if (person.Type.toLowerCase() === 'writer') {
writers.value.push(person.Name)
}
}
}
console.log(writers.value, directors.value)
})
const arrayRange = (start: number, stop: number) =>
Array.from({ length: (stop - start) / 1 + 1 }, (value, index) => start + index * 1)
</script>
<template>
<div
class="container"
:style="{
backgroundAttachment: 'fixed',
backgroundSize: 'cover',
backgroundImage: `url(${jfapi.FullBackdropImageUrl(item ? item.Id : '', item ? item.BackdropImageTags[0] : '', 0)})`,
}"
>
<div class="content-wrapper">
<div class="item-info">
<div class="logo">
<img :src="`${jfapi.LogoImageUrl(item.Id, item.ImageTags.Logo)}`" alt="" />
<MediaInfo />
</div>
<div class="info-wrapper">
<div class="section">
<div class="detail-buttons">
<div class="play-button">
<button class="primary-button">
<font-awesome-icon icon="fa-solid fa-play" size="md" />Play Now
</button>
</div>
<div class="user-buttons">
<div class="watched">
<font-awesome-icon icon="fa-solid fa-check" size="xl" class="clickable" />
</div>
<div class="favorited">
<font-awesome-icon :icon="['far', 'heart']" size="xl" class="clickable" />
</div>
</div>
</div>
<div class="media-streams">
<div class="stream-type">Video</div>
<div class="stream-value">{{ item?.MediaStreams[0].DisplayTitle }}</div>
<div class="stream-type">Audio</div>
<div class="stream-selector" v-if="numAudioStreams > 1">
<select name="audio-stream-select" :id="`${item?.Id}-audio-select`">
<option
:value="`${item?.MediaStreams}`"
v-for="i in arrayRange(1, numAudioStreams)"
:key="`audio-stream-${i}`"
>
{{ item?.MediaStreams[i].DisplayTitle }}
</option>
</select>
</div>
<div class="stream-value" v-else>{{ item?.MediaStreams[1].DisplayTitle }}</div>
<div class="stream-type">Subtitles</div>
<div class="stream-selector">
<select
name="subtitle-stream-select"
:id="`${item?.Id}-subtitle-select`"
v-if="item?.MediaStreams.length > numAudioStreams + 1"
>
<option value="Off">Off</option>
<option
:value="`${item?.MediaStreams}`"
v-for="i in arrayRange(numAudioStreams + 1, item.MediaStreams.length - 1)"
:key="`subtitle-stream-${i}`"
>
{{ item?.MediaStreams[i].DisplayTitle }}
</option>
</select>
<div class="stream-value" v-else>None</div>
</div>
</div>
</div>
<blockquote class="tagline" v-if="item?.Taglines.length > 0">
{{ item?.Taglines[0] }}
</blockquote>
<div class="overview">
{{ item?.Overview }}
</div>
<div class="misc-info">
<div class="info-type">Genre</div>
<div class="info-value">{{ item?.Genres.join(', ') }}</div>
<div class="info-type">{{ 'Director' + (directors.length > 1 ? 's' : '') }}</div>
<div class="info-value">{{ directors.join(', ') }}</div>
<div class="info-type">{{ 'Writer' + (writers.length > 1 ? 's' : '') }}</div>
<div class="info-value">{{ writers.join(', ') }}</div>
<div class="info-type">{{ 'Studio' + (item?.Studios.length > 1 ? 's' : '') }}</div>
<div class="info-value">{{ item?.Studios.map((s) => s.Name).join(', ') }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.container {
min-height: 100vh;
}
.content-wrapper {
width: 95%;
margin: auto;
padding-top: 3em;
}
.item-info {
padding: 3em 5em;
width: 900px;
min-height: 80vh;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--color-text);
}
.info-wrapper {
display: flex;
flex-direction: column;
align-items: baseline;
justify-content: space-between;
gap: 3em;
width: 95%;
margin: 0 auto;
margin-top: 3em;
}
.logo {
display: flex;
flex-direction: column;
align-items: center;
}
.logo img {
max-height: 230px;
}
.section {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.detail-buttons {
display: flex;
justify-content: space-around;
align-items: center;
flex-direction: column;
gap: 1em;
margin-left: 2em;
}
.user-buttons {
display: flex;
justify-content: space-between;
gap: 2em;
}
.media-streams {
display: grid;
grid-template-columns: 0.5fr 4fr;
}
.stream-type {
text-align: right;
padding: 0.2em;
margin-right: 0.3em;
color: var(--color-text-faded);
}
.stream-value {
padding: 0.2em;
}
.stream-selector {
margin: 0.2em;
}
.stream-selector select {
width: 100%;
}
.misc-info {
display: grid;
grid-template-columns: 0.5fr 4fr;
}
.misc-info .info-type {
text-align: right;
margin-right: 0.5em;
color: var(--color-text-faded);
}
.tagline {
position: relative;
}
.tagline::before {
content: '\201C';
font-size: 156px;
position: absolute;
top: -80px;
left: -20px;
color: rgba(0, 0, 0, 0.4);
z-index: -1;
font-family: 'Georgia', Times, serif;
}
</style>

@ -1,28 +1,18 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import JF from '@/jfapi'
import { ref } from 'vue'
const username = ref('')
const host = ref('http://192.168.0.63:30013')
const password = ref('')
const login = () => {
jfapi.Login(host.value, username.value, password.value).then((data) => {
console.log(data)
if (data !== null) {
localStorage.setItem('jf_userid', data.Id)
localStorage.setItem('jf_host', data.Host)
JF.authenticateUserByName(username.value, password.value).then(() => {
window.location.href = '/'
}
})
}
</script>
<template>
<div class="login-wrapper">
<div class="form-item">
<div class="label">Server Address</div>
<input type="text" v-model="host" />
</div>
<div class="form-item">
<div class="label">Username</div>
<input type="text" v-model="username" />

Loading…
Cancel
Save