infinite scrolling fixed

main
Gabe Farrell 1 year ago
parent 1cd1584bb3
commit 579d78ba43

Binary file not shown.

@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap"
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Quicksand:wght@300..700&display=swap"
rel="stylesheet"
/>
<title>Vite App</title>

@ -21,6 +21,8 @@
"@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@jellyfin/sdk": "^0.11.0",
"@vueuse/components": "^12.0.0",
"hls.js": "^1.5.17",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"

@ -2,6 +2,7 @@
import { RouterLink, RouterView } from 'vue-router'
import { ref } from 'vue'
import jfapi from './jfapi'
import { OnClickOutside } from '@vueuse/components'
const menuOpen = ref(false)
const logout = () => {
@ -14,18 +15,27 @@ const logout = () => {
}
</script>
<template>
<div class="primary-nav no-select" v-if="!$route.meta.isLoginPage">
<div class="menu-toggle" @click="menuOpen = !menuOpen">
<font-awesome-icon icon="fa-solid fa-bars" size="xl" />
<OnClickOutside @trigger="menuOpen = false">
<div
class="primary-nav no-select"
v-if="!$route.meta.isLoginPage && !$route.meta.isVideoPlayer"
>
<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" key="home">Home</RouterLink>
<div class="menu-item">Settings</div>
<div class="menu-item" @click="logout">Sign Out</div>
</div>
</div>
<div class="menu" v-show="menuOpen">
<RouterLink class="menu-item" to="/home">Home</RouterLink>
<div class="menu-item">Settings</div>
<div class="menu-item" @click="logout">Sign Out</div>
</div>
</div>
</OnClickOutside>
<div class="wrapper">
<RouterView />
<router-view v-slot="{ Component, route }">
<transition name="fade">
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</div>
</template>
@ -40,8 +50,8 @@ const logout = () => {
.menu-toggle {
position: absolute;
top: 30px;
left: 30px;
top: 20px;
left: 20px;
}
.menu-toggle:hover {
cursor: pointer;

@ -0,0 +1,74 @@
<script setup lang="ts">
import type { Item } from '@/jfapi'
import jfapi from '@/jfapi'
import { RouterLink } from 'vue-router'
defineProps<{
item: Item
}>()
</script>
<template>
<div class="episode-thumb no-select">
<div class="episode-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img :src="jfapi.PrimaryImageUrl(item.Id, item.ImageTags.Primary || '')" alt="" />
</div>
<RouterLink class="episode-title" :to="`/item/${item.Id}`" :key="item.Id">
{{ `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` }}
</RouterLink>
</div>
</template>
<style scoped>
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
-webkit-transform: translate(-50%, -50%);
-moz-transform: translate(-50%, -50%);
width: 35px;
height: 35px;
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(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: background 10ms linear 0ms;
opacity: 0%;
}
.play-icon:hover {
background: rgba(122, 122, 122, 0.2);
cursor: pointer;
}
.episode-img {
position: relative;
}
.episode-img:hover .play-icon {
opacity: 100%;
transition:
all var(--hover-animation-speed) ease,
z-index 1ms;
}
.episode-thumb {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1em;
gap: 0.5em;
}
.episode-thumb img {
width: 300px;
border-radius: 15px;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.episode-title {
max-width: 240px;
text-align: center;
font-size: 14px;
}
</style>

@ -3,25 +3,21 @@ 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[]
// }>()
import jfapi from '@/jfapi'
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 : '')
views.value = JSON.parse(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" />
<div class="latest" v-if="viewstring != null">
<LatestMedia v-for="view of views" :view="view" :key="view.Id" />
</div>
</template>
<style scoped></style>

@ -3,7 +3,7 @@ import jfapi from '@/jfapi'
import MediaScroller from './MediaScroller.vue'
import type { FeedItem } from '@/feed'
import type { View } from '@/jfapi'
import { getDisplayDuration, getImageLink } from './utils'
import { getDisplayDuration, getImageLink } from '../utils'
const props = defineProps<{
view: View
@ -17,29 +17,22 @@ const Items = async (): Promise<FeedItem[]> => {
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: '',
summary: item.Overview,
infoSubtext:
item.Type === 'Episode'
? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
: item.OriginalTitle != item.Name
? item.OriginalTitle
: '',
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
date: String(item.ProductionYear),
@ -49,6 +42,7 @@ const Items = async (): Promise<FeedItem[]> => {
? String(item.UserData.UnplayedItemCount)
: '',
itemId: item.Id,
type: item.Type,
})
}
console.log('LatestMedia.vue =>', feed)

@ -1,25 +1,32 @@
<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 { getImageLink, getDisplayDuration } from '../utils'
import { onBeforeMount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ScrollerItemPortrait from './ScrollerItemPortrait.vue'
const route = useRoute()
const viewId = String(route.params.id)
let index = 0
let totalItems = 0
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)
const data = await jfapi.GetItemsInView(userid ? userid : '', viewId, type, index)
if (data !== null) {
console.log(data)
index = data.StartIndex + 100
totalItems = data.TotalRecordCount
if (index > totalItems) {
index = totalItems
}
console.log('Index =>', index)
for (const item of data.Items) {
const summary = ''
if (userid === null) {
window.location.href = '/login'
}
@ -29,8 +36,8 @@ const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
: String(item.ProductionYear),
image: getImageLink(item),
summary: summary,
infoSubtext: '',
summary: item.Overview,
infoSubtext: item.OriginalTitle == item.Name ? '' : item.OriginalTitle,
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
date: String(item.ProductionYear),
@ -40,6 +47,7 @@ const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
? String(item.UserData.UnplayedItemCount)
: '',
itemId: item.Id,
type: item.Type,
})
}
console.log('MediaGrid.vue =>', feed)
@ -50,26 +58,49 @@ const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
const items = ref<FeedItem[]>()
const views = ref<View[]>()
const viewstring = ref<string | null>('')
const viewType = ref('')
let loading = false
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) {
views.value = JSON.parse(viewstring.value || '')
for (const view of views.value || []) {
if (view.Id === viewId) {
if (view.CollectionType.toLowerCase() === 'movies') {
viewType = 'Movie'
viewType.value = 'Movie'
} else if (view.CollectionType.toLowerCase() === 'tvshows') {
viewType = 'Series'
viewType.value = 'Series'
}
}
}
console.log('ItemType =>', viewType)
const data = ref<FeedItem[]>()
console.log(viewType)
data.value = await Items(viewId, viewType)
data.value = await Items(viewId, viewType.value)
items.value = data.value
})
const onLoadMore = async () => {
if (index === totalItems || loading) {
return
}
loading = true
const more = await Items(viewId, viewType.value)
items.value?.push(...more)
loading = false
}
onMounted(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
onLoadMore()
}
})
const el = document.querySelector('.loading-trigger')
if (el) {
console.log('observing!')
observer.observe(el)
}
})
</script>
<template>
@ -77,27 +108,18 @@ onBeforeMount(async () => {
<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"
:item="item"
:key="item.itemId"
class="scroller-item"
/>
<div class="loading-trigger"></div>
</div>
</div>
</template>
<style scoped>
.media-grid {
width: 80%;
max-width: 80%;
margin: auto;
margin-top: 3em;
}

@ -70,35 +70,16 @@ const disableForward = () => {
<div class="scroller-wrapper" v-if="landscape">
<ScrollerItemLandscape
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"
:itemId="item.itemId"
:key="item.title"
:item="item"
:key="item.itemId"
class="scroller-item"
/>
</div>
<div class="scroller-wrapper" v-else>
<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"
:item="item"
:key="item.itemId"
class="scroller-item"
/>
</div>

@ -7,25 +7,18 @@ import type { Item } from '@/jfapi'
const Items = async (): Promise<FeedItem[]> => {
return new Promise(async (resolve) => {
const feed: FeedItem[] = []
const data = await jfapi.GetNextUp()
const userid = localStorage.getItem('jf_userid')
const data = await jfapi.GetNextUp(userid || '')
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,
summary: item.Overview,
infoSubtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,
imdbRating: String(item.CommunityRating),
runtime: getDisplayDuration(item.RunTimeTicks),
@ -33,6 +26,8 @@ const Items = async (): Promise<FeedItem[]> => {
MPAA: item.OfficialRating,
itemId: item.Id,
tag: '',
seriesId: item.SeriesId,
type: item.Type,
})
}
console.log('NextUp.vue =>', feed)

@ -1,57 +1,9 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import { ref } from 'vue'
const props = defineProps({
title: {
type: String,
required: true,
},
subtext: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
summary: {
type: String,
// required: true,
},
infoSubtext: {
type: String,
},
date: {
type: String,
// required: true,
},
runtime: {
type: String,
// required: true,
},
imdbRating: {
type: String,
// required: true,
},
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)
}
})
import type { FeedItem } from '@/feed'
const props = defineProps<{
item: FeedItem
}>()
</script>
<template>
@ -64,7 +16,7 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
<div class="scroller-item-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="props.image"
:src="props.item.image"
alt=""
:style="{
width: `${240}px`,
@ -73,33 +25,42 @@ 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-subtext">{{ props.infoSubtext }}</div>
<RouterLink
class="info-box-title clamp"
:to="`/item/${props.item.seriesId}`"
:key="props.item.seriesId"
>{{ props.item.title }}</RouterLink
>
<div class="info-box-subtext">{{ props.item.infoSubtext }}</div>
</div>
<div class="info-box-summary clamp-4">{{ overview }}</div>
<div class="info-box-summary clamp-4">{{ props.item.summary }}</div>
<div class="info-box-misc-info">
<div class="date">{{ props.date }}</div>
<div class="runtime">{{ props.runtime }}</div>
<div class="date">{{ props.item.date }}</div>
<div class="runtime">{{ props.item.runtime }}</div>
<div class="imdb">
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ props.imdbRating }}
{{ props.item.imdbRating }}
</div>
<div class="mpaa" v-show="props.MPAA !== undefined">
<span class="age-rating sm"> {{ props.MPAA }}</span>
<div class="mpaa" v-show="props.item.MPAA !== undefined">
<span class="age-rating sm"> {{ props.item.MPAA }}</span>
</div>
<div class="ends-at">Ends at 6:23pm</div>
</div>
</div>
</div>
</div>
<div class="scroller-item-title clamp-2" :style="{ maxWidth: `${240 - 5}px` }">
{{ props.title }}
</div>
<div class="scroller-item-subtext clamp" :style="{ maxWidth: `${240 - 5}px` }">
{{ props.subtext }}
</div>
<RouterLink
:to="`/item/${props.item.itemId}`"
class="scroller-item-description"
:key="props.item.itemId"
>
<div class="scroller-item-title clamp-2" :style="{ maxWidth: `${240 - 5}px` }">
{{ props.item.title }}
</div>
<div class="scroller-item-subtext clamp" :style="{ maxWidth: `${240 - 5}px` }">
{{ props.item.subtext }}
</div></RouterLink
>
</div>
</template>

@ -1,107 +1,56 @@
<script setup lang="ts">
import jfapi from '@/jfapi'
import { ref } from 'vue'
import type { FeedItem } from '@/feed'
import { RouterLink } from 'vue-router'
const props = defineProps({
title: {
type: String,
required: true,
},
subtext: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
summary: {
type: String,
// required: true,
},
infoSubtext: {
type: String,
},
date: {
type: String,
// required: true,
},
runtime: {
type: String,
// required: true,
},
imdbRating: {
type: String,
// required: true,
},
MPAA: {
type: String,
},
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
: ''
}
})
const props = defineProps<{
item: FeedItem
}>()
</script>
<template>
<div class="scroller-item">
<div class="scroller-item-img">
<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="poster-tag" v-if="props.item.tag !== '' && props.item.tag !== undefined">
<div class="text-tag" v-if="props.item.tag !== '0'">{{ props.item.tag }}</div>
<div class="icon-tag" v-else>
<font-awesome-icon icon="fa-solid fa-check" size="md" class="clickable" />
</div>
</div>
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="props.image"
alt=""
:style="{
width: `${120}px`,
height: `${120 * (3 / 2)}px`,
}"
/>
<div class="poster-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
loading="lazy"
:src="props.item.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 }}
<RouterLink
class="info-box-title clamp"
:to="`/item/${props.item.itemId}`"
:key="props.item.itemId"
>{{ props.item.title }}</RouterLink
>
<div class="info-box-subtext" v-show="item.infoSubtext !== ''">
{{ item.infoSubtext }}
</div>
</div>
<div class="info-box-summary clamp-6">{{ overview }}</div>
<div class="info-box-summary clamp-6">{{ props.item.summary }}</div>
<div class="info-box-misc-info">
<div class="date">{{ props.date }}</div>
<div class="runtime">{{ props.runtime }}</div>
<div class="date">{{ props.item.date }}</div>
<div class="runtime">{{ props.item.runtime }}</div>
<div class="imdb">
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ props.imdbRating }}
{{ props.item.imdbRating }}
</div>
<div class="mpaa" v-show="props.MPAA !== undefined">
<span class="age-rating sm">{{ props.MPAA }}</span>
<div class="mpaa" v-show="props.item.MPAA !== undefined">
<span class="age-rating sm">{{ props.item.MPAA }}</span>
</div>
<div class="ends-at">Ends at 6:23pm</div>
</div>
@ -109,10 +58,10 @@ jfapi.GetItem(userid ? userid : '', props.itemId).then((data) => {
</div>
</div>
<div class="scroller-item-title clamp-2" :style="{ maxWidth: `${120 - 5}px` }">
{{ props.title }}
{{ props.item.title }}
</div>
<div class="scroller-item-subtext clamp" :style="{ maxWidth: `${120 - 5}px` }">
{{ props.subtext }}
{{ props.item.subtext }}
</div>
</div>
</template>

@ -1,54 +1,3 @@
// 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
subtext: string
@ -61,265 +10,8 @@ type FeedItem = {
MPAA: string
tag: string
itemId: string
type: string // Episode | Series | Season | Movie
seriesId?: 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 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 }

@ -29,14 +29,6 @@ type View = {
ParentId: string
}
type ViewList = {
Items: View[]
}
type NextUp = {
Items: Item[]
}
type Item = {
Name: string
OriginalTitle: string
@ -48,19 +40,22 @@ type Item = {
CommunityRating: number
ProductionYear: number
IndexNumber?: number
IsFolder: boolean
ParentIndexNumber?: number
SeriesName?: string
SeriesId?: string
Overview: string
RunTimeTicks: number
BackdropImageTags: string[]
ParentLogoItemId?: string
ParentLogoImageTag?: string
ParentThumbItemId?: string
ParentThumbImageTag?: string
ParentBackdropItemId?: string
ParentBackdropImageTags?: string[]
SeriesPrimaryImageTag?: string
UserData?: UserData
MediaStreams: MediaStream[]
MediaStreams?: MediaStream[]
Taglines: string[]
Genres: string[]
Studios: Studio[]
@ -89,10 +84,12 @@ type MediaStream = {
IsForced: boolean
}
type ItemList = {
Items: Item[]
StartIndex: number
TotalRecordCount: number
type MediaSegment = {
EndTicks: number
Id: string
ItemId: string
StartTicks: number
Type: string // Intro | Outro
}
type UserData = {
@ -107,21 +104,64 @@ type ImageTags = {
type User = {
Name: string
Host: string
Id: string
Views?: View[]
}
export type { User, View, Item, ViewList, ItemList, MediaStream }
type Records<T> = {
Items: T[]
StartIndex: number
TotalRecordCount: number
}
export type { User, View, Item, Records, MediaStream, MediaSegment }
const generateId = () => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ' '
const charactersLength = characters.length
for (let i = 0; i < 64; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}
class JFApi {
buildAuthHeader(): string {
let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) {
deviceId = generateId()
localStorage.setItem('jf_deviceId', deviceId)
}
return `MediaBrowser Client="Featherfin", Device="${detectBrowser()}", DeviceId="${deviceId}", Version="v0.0.1", Token="${localStorage.getItem('jfauth_token')}"`
}
buildRequest(endpoint: Endpoint): Request {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) {
deviceId = generateId()
localStorage.setItem('jf_deviceId', deviceId)
}
return new Request(`${host}${endpoint.resource}`, {
method: endpoint?.method,
headers: {
Authorization: this.buildAuthHeader(),
},
})
}
request<T>(endpoint: Endpoint): Promise<T | null> {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) {
deviceId = generateId()
localStorage.setItem('jf_deviceId', deviceId)
}
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')}"`,
Authorization: this.buildAuthHeader(),
},
})
.then(async (response) => {
@ -134,7 +174,7 @@ class JFApi {
})
})
}
LoadViews(userid: string): Promise<ViewList | null> {
LoadViews(userid: string): Promise<Records<View> | null> {
return this.request({
resource: `/UserViews?userId=${userid}`,
method: 'GET',
@ -142,28 +182,105 @@ class JFApi {
}
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}`,
resource: `/Users/${userid}/Items/Latest?Limit=16&Fields=PrimaryImageAspectRatio%2COverview%2CPath%2COriginalTitle&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb&ParentId=${view.Id}`,
method: 'GET',
})
}
GetNextUp(): Promise<NextUp | null> {
GetNextUp(userId: string): Promise<Records<Item> | 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`,
resource: `/Shows/NextUp?Limit=24&Fields=PrimaryImageAspectRatio%2CDateCreated%2CPath%2CMediaSourceCount%2COverview%2COriginalTitle&UserId=${userId}&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> {
GetNextUpInSeries(userId: string, seriesId: string): Promise<Records<Item> | null> {
return this.request({
resource: `/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount%2COverview%2COriginalTitle`,
method: 'GET',
})
}
GetSeasons(userId: string, seriesId: string): Promise<Records<Item> | null> {
return this.request({
resource: `/Shows/${seriesId}/Seasons?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount`,
method: 'GET',
})
}
GetEpisodes(userId: string, seriesId: string, seasonId: string): Promise<Records<Item> | null> {
return this.request({
resource: `/Shows/${seriesId}/Episodes?seasonId=${seasonId}&userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle`,
method: 'GET',
})
}
GetFirstEpisode(userId: string, seriesId: string): Promise<Records<Item> | null> {
return this.request({
resource: `/Shows/${seriesId}/Episodes?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle&limit=1`,
method: 'GET',
})
}
GetItem(userid: string, itemId: string): Promise<Item | null> {
const req = this.buildRequest({
resource: `/Items/${itemId}?userId=${userid}`,
method: 'GET',
})
return new Promise((resolve) => {
caches.open('jfapi_item_cache').then((cache) => {
cache.match(req).then((response) => {
if (response) {
resolve(response.json() as Promise<Item>)
} else {
fetch(req)
.then(async (response) => {
if (response.status === 401) {
window.location.href = '/login'
}
await cache.put(req, response.clone())
resolve(response.json() as Promise<Item | null>)
})
.catch((error) => {
console.log(error)
resolve(null)
})
}
})
})
})
}
GetItemsInView(userid: string, viewId: string, type: string, startIndex: number) {
return this.request<Records<Item>>({
resource: `/Users/${userid}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=${type}&Recursive=true&Fields=PrimaryImageAspectRatio%2COverview%2COriginalTitle&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CBanner%2CThumb&StartIndex=${startIndex}&Limit=100&ParentId=${viewId}`,
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}`,
GetIntroOutroSegments(itemId: string): Promise<Records<MediaSegment> | null> {
return this.request({
resource: `/MediaSegments/${itemId}?includeSegmentTypes=Outro&includeSegmentTypes=Intro`,
method: 'GET',
})
}
PostProgress(userId: string, itemId: string, sessionId: string, ticks: number) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
fetch(`${host}/Sessions/Playing/Progress`, {
body: JSON.stringify({
CanSeek: true,
ItemId: itemId,
SessionId: sessionId, // TODO
MediaSourceId: itemId,
AudioStreamIndex: 1, // TODO
SubtitleStreamIndex: 3, // TODO
IsPaused: false,
IsMuted: false,
PositionTicks: ticks,
VolumeLevel: 100,
PlayMethod: 'Transcode',
PlaySessionId: sessionId, // same as SessionId
RepeatMode: 'RepeatAll',
}),
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.buildAuthHeader(),
},
})
}
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}`
@ -185,13 +302,18 @@ class JFApi {
return `${host}/Items/${itemId}/Images/Logo?tag=${imgTag}`
}
Login(host: string, username: string, password: string): Promise<User | null> {
let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) {
deviceId = generateId()
localStorage.setItem('jf_deviceId', deviceId)
}
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')}"`,
Authorization: this.buildAuthHeader(),
},
})
.then((r) => r.json().then((data) => ({ status: r.status, body: data })))
@ -205,7 +327,6 @@ class JFApi {
resolve({
Name: data.body.User.Name,
Id: data.body.User.Id,
Host: 'http://192.168.0.63:30013',
})
}
})
@ -241,5 +362,16 @@ Authentication is done by appending 'Token="MyTokenXYZ123"' to Authorization hea
- /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
- /Users/User.Id/PlayedItems/Item.Id - DELETE: remove played status; POST w/ DatePlayed=2024-12-04T20%3A18%3A28.936Z: add played status (GMT time zone)
- /Users/User.Id/FavoriteItems/Item.Id - DELETE: remove favorite status; POST: add favorite status
- /Sessions/Logout - logout
Play video flow:
- POST /PlayBackInfo
Body (among other things): AudioStreamIndex, SubtitleStreamIndex, StartTimeTicks...
Response (among other things): Bitrate, TranscodingUrl, RunTimeTicks, PlaySessionId, ETag
When not transcoding, GET /Videos/Item.Id/stream.mp4/Static=true&mediaSourceId=Item.Id&deviceId=Device.Id&api_key={}&Tag=ETag
- POST /Sessions/Playing
Body (among other things): AudioStreamIndex, SubtitleStreamIndex, PlayMethod
- GET /System/Configuration/encoding: seems to just return server transcoding capabilities
*/

@ -3,6 +3,7 @@ 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 VideoView from '@/views/VideoView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -20,6 +21,9 @@ const router = createRouter({
{
path: 'view/:id',
component: MediaGrid,
meta: {
transition: 'slide-right',
},
},
],
},
@ -33,6 +37,19 @@ const router = createRouter({
},
],
},
{
path: '/play',
redirect: '/home',
children: [
{
path: ':id',
component: VideoView,
},
],
meta: {
isVideoPlayer: true,
},
},
{
path: '/login',
name: 'login',

@ -1,17 +1,29 @@
<script setup lang="ts">
import MediaShowcase from '@/components/MediaShowcase.vue'
import type { View, ViewList } from '@/jfapi'
import type { Records, View } 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 viewlist = ref<Records<View> | null>()
const userid = localStorage.getItem('jf_userid')
const host = localStorage.getItem('jf_host')
const viewstring = localStorage.getItem('jf_views')
if (viewstring !== null && viewstring !== 'undefined' && viewstring !== '') {
console.log('viewstring is valid')
views.value = JSON.parse(viewstring)
} else {
viewlist.value = await jfapi.LoadViews(userid ? userid : '')
if (viewlist.value === null) {
// error
} else {
views.value = viewlist.value.Items
localStorage.setItem('jf_views', JSON.stringify(viewlist.value ? viewlist.value.Items : ''))
}
}
console.log('Host =>', host)
viewlist.value = await jfapi.LoadViews(userid ? userid : '')
if (window.location.pathname.toLowerCase() !== '/login') {
if (host === null) {
window.location.href = '/login'
@ -19,8 +31,6 @@ onBeforeMount(async () => {
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)
})
@ -37,7 +47,11 @@ const viewIsActive = (id: string) => {
<main>
<MediaShowcase />
<div class="home-nav">
<RouterLink to="/home" class="home-nav-item" :class="homeIsActive() ? 'active' : ''"
<RouterLink
to="/home"
class="home-nav-item"
:class="homeIsActive() ? 'active' : ''"
key="home"
>Home</RouterLink
>
<RouterLink
@ -54,6 +68,7 @@ const viewIsActive = (id: string) => {
><component :is="Component" :key="route.path"
/></RouterView>
</div>
<div class="footer"></div>
</main>
</template>
@ -86,4 +101,7 @@ a {
.media {
margin: 0 10%;
}
.footer {
height: 100px;
}
</style>

@ -1,8 +1,9 @@
<script setup lang="ts">
import EpisodeThumb from '@/components/EpisodeThumb.vue'
import MediaInfo from '@/components/MediaInfo.vue'
import jfapi, { type Item } from '@/jfapi'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, RouterLink } from 'vue-router'
const route = useRoute()
@ -11,19 +12,56 @@ const itemId = String(route.params.id)
const item = ref<Item>()
const directors = ref<string[]>([])
const writers = ref<string[]>([])
// const studios = ref<string[]>()
const selectSubTrack = ref('')
const selectAudioTrack = ref('')
const nextUp = ref<Item>()
const numAudioStreams = ref(0)
const userid = localStorage.getItem('jf_userid')
jfapi.GetItem(userid ? userid : '', itemId).then((data) => {
const userid = localStorage.getItem('jf_userid') || ''
jfapi.GetItem(userid, itemId).then((data) => {
if (data === null) {
// error
} else {
item.value = data
if (item.value.IsFolder) {
console.log('Item is a Series')
// item is a full series
jfapi.GetNextUpInSeries(userid, item.value.Id || '').then((data) => {
if (data?.Items && data.Items.length > 0) {
nextUp.value = data.Items[0]
} else {
jfapi.GetFirstEpisode(userid, item.value?.Id).then((data) => {
if (data?.Items) {
nextUp.value = data.Items[0]
}
})
}
})
}
}
if (item.value !== undefined) {
if (item.value !== undefined && item.value.MediaStreams !== undefined) {
for (const stream of item.value.MediaStreams) {
if (stream.Type.toLowerCase() === 'audio') {
numAudioStreams.value++
if (stream.IsForced && !selectAudioTrack.value.includes('Forced')) {
selectAudioTrack.value = stream.DisplayTitle
} else if (
stream.IsDefault &&
!selectAudioTrack.value.includes('Forced') &&
!selectAudioTrack.value.includes('Default')
) {
selectAudioTrack.value = stream.DisplayTitle
}
} else if (stream.Type.toLowerCase() === 'subtitle') {
if (stream.IsForced && !selectSubTrack.value.includes('Forced')) {
selectSubTrack.value = stream.DisplayTitle
} else if (
stream.IsDefault &&
!selectSubTrack.value.includes('Forced') &&
!selectSubTrack.value.includes('Default')
) {
console.log(stream)
selectSubTrack.value = stream.DisplayTitle
}
}
}
for (const person of item.value.People) {
@ -42,26 +80,48 @@ const arrayRange = (start: number, stop: number) =>
<template>
<div
v-if="item"
class="container"
:style="{
backgroundAttachment: 'fixed',
backgroundSize: 'cover',
backgroundImage: `url(${jfapi.FullBackdropImageUrl(item ? item.Id : '', item ? item.BackdropImageTags[0] : '', 0)})`,
backgroundImage: `url(${
item.ParentBackdropItemId && item.ParentBackdropImageTags
? jfapi.FullBackdropImageUrl(
item.ParentBackdropItemId,
item.ParentBackdropImageTags[0],
0,
)
: jfapi.FullBackdropImageUrl(item.Id, 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 class="media-title">
<div class="logo">
<img
v-if="item.ImageTags.Logo || item.ParentLogoImageTag"
:src="`${
item.ParentLogoImageTag && item.ParentLogoItemId
? jfapi.LogoImageUrl(item.ParentLogoItemId, item.ParentLogoImageTag)
: jfapi.LogoImageUrl(item.Id, item.ImageTags.Logo)
}`"
alt=""
/>
<div class="title" v-else>{{ item.SeriesName ? item.SeriesName : item.Name }}</div>
<MediaInfo />
</div>
</div>
<div class="info-wrapper">
<div class="section">
<div class="detail-buttons">
<div :class="`detail-buttons ${!item.IsFolder ? '' : 'wide'}`">
<div class="play-button">
<button class="primary-button">
<font-awesome-icon icon="fa-solid fa-play" size="md" />Play Now
</button>
<RouterLink :to="`/play/${item.Id}`">
<button class="primary-button">
<font-awesome-icon icon="fa-solid fa-play" size="md" />Play Now
</button>
</RouterLink>
</div>
<div class="user-buttons">
<div class="watched">
@ -72,39 +132,46 @@ const arrayRange = (start: number, stop: number) =>
</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 class="media-streams" v-if="item.MediaStreams !== undefined">
<div class="info-item">
<div class="stream-type">Video</div>
<div class="stream-value">{{ item?.MediaStreams[0].DisplayTitle }}</div>
</div>
<div class="info-item">
<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>
<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}`"
<div class="info-item">
<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"
>
{{ item?.MediaStreams[i].DisplayTitle }}
</option>
</select>
<div class="stream-value" v-else>None</div>
<option value="Off">Off</option>
<option
:value="`${item?.MediaStreams[i].DisplayTitle}`"
v-for="i in arrayRange(numAudioStreams + 1, item.MediaStreams.length - 1)"
:selected="item?.MediaStreams[i].DisplayTitle === selectSubTrack"
:key="`subtitle-stream-${i}`"
>
{{ item?.MediaStreams[i].DisplayTitle }}
</option>
</select>
<div class="stream-value" v-else>None</div>
</div>
</div>
</div>
</div>
@ -115,14 +182,26 @@ const arrayRange = (start: number, stop: number) =>
{{ 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 v-if="item.Genres.length > 0" class="info-item">
<div class="info-type">Genre</div>
<div class="info-value">{{ item?.Genres.join(', ') }}</div>
</div>
<div v-if="directors.length > 0" class="info-item">
<div class="info-type">{{ 'Director' + (directors.length > 1 ? 's' : '') }}</div>
<div class="info-value">{{ directors.join(', ') }}</div>
</div>
<div v-if="writers.length > 0" class="info-item">
<div class="info-type">{{ 'Writer' + (writers.length > 1 ? 's' : '') }}</div>
<div class="info-value">{{ writers.join(', ') }}</div>
</div>
<div v-if="item.Studios.length > 0" class="info-item">
<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 class="next-up" v-if="nextUp">
Next Up
<EpisodeThumb :item="nextUp" />
</div>
</div>
</div>
@ -137,12 +216,12 @@ const arrayRange = (start: number, stop: number) =>
.content-wrapper {
width: 95%;
margin: auto;
padding-top: 3em;
padding: 3em 0;
}
.item-info {
padding: 3em 5em;
width: 900px;
min-height: 80vh;
padding: 3em 4em;
width: 800px;
min-height: 90vh;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
@ -157,17 +236,32 @@ const arrayRange = (start: number, stop: number) =>
flex-direction: column;
align-items: baseline;
justify-content: space-between;
gap: 3em;
gap: 1.5em;
width: 95%;
margin: 0 auto;
margin-top: 3em;
margin-top: 2em;
}
.media-title {
min-height: 200px;
max-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
}
.title {
font-size: 52px;
font-weight: bold;
font-family: 'DM Sans', sans-serif;
}
.logo {
display: flex;
flex-direction: column;
align-items: center;
gap: 2em;
margin: auto 0;
}
.logo img {
max-width: 700px;
max-height: 230px;
}
.section {
@ -183,6 +277,11 @@ const arrayRange = (start: number, stop: number) =>
flex-direction: column;
gap: 1em;
margin-left: 2em;
min-width: 200px;
}
.wide {
flex-direction: row;
gap: 3em;
}
.user-buttons {
display: flex;
@ -190,42 +289,58 @@ const arrayRange = (start: number, stop: number) =>
gap: 2em;
}
.media-streams {
display: grid;
grid-template-columns: 0.5fr 4fr;
display: flex;
flex-direction: column;
}
.media-streams .info-item {
display: flex;
align-items: center;
gap: 1em;
}
.stream-type {
text-align: right;
padding: 0.2em;
margin-right: 0.3em;
color: var(--color-text-faded);
width: 100px;
flex-shrink: 0;
}
.stream-value {
padding: 0.2em;
font-size: 14px;
word-wrap: break-word;
max-width: 300px;
}
.stream-selector {
margin: 0.2em;
max-width: 300px;
}
.stream-selector select {
width: 100%;
}
.misc-info {
display: grid;
grid-template-columns: 0.5fr 4fr;
display: flex;
flex-direction: column;
}
.info-item {
display: flex;
align-items: baseline;
gap: 1em;
}
.misc-info .info-type {
text-align: right;
margin-right: 0.5em;
color: var(--color-text-faded);
width: 70px;
}
.misc-info .info-value {
font-size: 14px;
max-width: 500px;
}
.tagline {
position: relative;
padding-left: 1em;
}
.tagline::before {
content: '\201C';
font-size: 156px;
position: absolute;
top: -80px;
left: -20px;
left: -10px;
color: rgba(0, 0, 0, 0.4);
z-index: -1;
font-family: 'Georgia', Times, serif;

@ -2,7 +2,7 @@
import jfapi from '@/jfapi'
import { ref } from 'vue'
const username = ref('')
const host = ref('http://192.168.0.63:30013')
const host = ref('http://192.168.0.110:8096')
const password = ref('')
const login = () => {
@ -10,7 +10,7 @@ const login = () => {
console.log(data)
if (data !== null) {
localStorage.setItem('jf_userid', data.Id)
localStorage.setItem('jf_host', data.Host)
localStorage.setItem('jf_host', host.value)
window.location.href = '/'
}
})

@ -0,0 +1,42 @@
<script setup lang="ts">
import Hls from 'hls.js'
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const itemId = String(route.params.id)
console.log(itemId)
onMounted(() => {
const player = document.getElementById(itemId) as HTMLVideoElement
if (Hls.isSupported() && player !== null) {
const hls = new Hls()
hls.loadSource(
`https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8`,
)
hls.attachMedia(player)
} else {
// Handle the case where HLS is not supported
console.error('HLS is not supported in this browser.')
}
})
</script>
<template>
<div class="video-player">
<video class="video-window" ref="video" controls :id="itemId"></video>
</div>
</template>
<style scoped>
.video-player {
width: 99.5vw;
height: 99.5vh;
margin: auto;
}
.video-window {
width: 100%;
height: 100%;
}
</style>
Loading…
Cancel
Save