chores + MediaInfo progress

main
Gabe Farrell 1 year ago
parent 7e7aa8f6f4
commit a93426ded2

@ -1,21 +1,28 @@
# featherfin
### TODO
## TODO
- [ ] Add episode title and SXXEXX to item screen for episode
- [ ] Enlarge Next Up section on Home
- [ ] Fix row spacing on Home
### Features
- [x] Add episode title and SXXEXX to item screen for episode
- [ ] Add Continue Watching section to Home
- [x] Make scroller item img click go to item page
- [x] Make scroller item play button click go to play page
- [ ] Finish Item page
- [ ] Add Actor List to all items except seasons
- [ ] Add episode list for series/seasons
- [ ] Add next episode list for episode
- [ ] Add related shows/movies to series and movies
- [ ] Finish implementing MediaInfo
- [ ] Move all MediaInfo stand-ins to the component
- [ ] Don't assume stream order
- [x] Finish implementing MediaInfo
- [ ] Make Like and Favorite buttons work
- [ ] Show state
- [ ] Update state
- [ ] Finish play page
- [ ] Play the correct media
- [ ] Play the correct media tracks
- [ ] Resume functionality
- [ ] Save watchtime when closing playback
- [ ] Make play buttons on media start playing
- [ ] Movies start playing movie
- [ ] Episodes play episode
@ -24,5 +31,18 @@
- [ ] Design UI elements
- [ ] Implement
- [ ] Add search
- [ ] Add search page
- [ ] Add search functionality
- [ ] Make featured media carousel work
- [ ] a lot
- [ ] Finish styling login page
### Chores
- [x] Enlarge Next Up section on Home
- [x] Fix row spacing on Home
- [ ] Add image blurs when waiting for images
- [ ] Add title tag to scroller item titles and subtitles
- [ ] Combine Portrait and Landscape scroller item components
- [x] Move all MediaInfo stand-ins to the component
- [ ] Replace all FeedItem references with jfapi.Item

@ -16,10 +16,7 @@ const logout = () => {
</script>
<template>
<OnClickOutside @trigger="menuOpen = false">
<div
class="primary-nav no-select"
v-if="!$route.meta.isLoginPage && !$route.meta.isVideoPlayer"
>
<div class="primary-nav no-select" v-if="!$route.meta.hideMenu">
<div class="menu-toggle" @click="menuOpen = !menuOpen">
<font-awesome-icon icon="fa-solid fa-bars" size="xl" />
</div>

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Item } from '@/jfapi'
import jfapi from '@/jfapi'
import { onBeforeMount, ref } from 'vue'
const props = defineProps<{
item: Item
}>()
// const episodes = ref<Item[]>([])
// onBeforeMount(async () => {
// const response = await jfapi.GetEpisodes(props.item.Id)
// })
</script>

@ -1,21 +1,25 @@
<script setup lang="ts">
import type { View } from '@/jfapi'
import type { Records, View } from '@/jfapi'
import NextUp from './NextUp.vue'
import LatestMedia from './LatestMedia.vue'
import { onBeforeMount, ref } from 'vue'
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)
onBeforeMount(async () => {
const viewlist = ref<Records<View> | null>()
viewlist.value = await jfapi.LoadViews()
if (viewlist.value === null) {
// error
} else {
views.value = viewlist.value.Items
}
console.log(views.value)
})
</script>
<template>
<NextUp />
<div class="latest" v-if="viewstring != null">
<div class="latest" v-if="views && views.length > 0">
<LatestMedia v-for="view of views" :view="view" :key="view.Id" />
</div>
</template>

@ -12,14 +12,10 @@ const props = defineProps<{
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)
const data = await jfapi.GetLatest(props.view)
if (data !== null) {
console.log(data)
for (const item of data) {
if (userid === null) {
window.location.href = '/login'
}
feed.push({
title: item.SeriesName || item.Name,
subtext: item.SeriesName

@ -17,8 +17,7 @@ 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, index)
const data = await jfapi.GetItemsInView(viewId, type, index)
if (data !== null) {
index = data.StartIndex + 100
totalItems = data.TotalRecordCount
@ -27,9 +26,6 @@ const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
}
console.log('Index =>', index)
for (const item of data.Items) {
if (userid === null) {
window.location.href = '/login'
}
feed.push({
title: item.SeriesName || item.Name,
subtext: item.SeriesName
@ -57,23 +53,16 @@ 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('')
const viewType = ref<string>('')
let loading = false
views.value = undefined
onBeforeMount(async () => {
viewstring.value = localStorage.getItem('jf_views')
views.value = JSON.parse(viewstring.value || '')
for (const view of views.value || []) {
if (view.Id === viewId) {
if (view.CollectionType.toLowerCase() === 'movies') {
viewType.value = 'Movie'
} else if (view.CollectionType.toLowerCase() === 'tvshows') {
viewType.value = 'Series'
}
}
viewType.value = localStorage.getItem('jf_view_' + viewId) || ''
if (viewType.value.toLowerCase() === 'movies') {
viewType.value = 'Movie'
} else if (viewType.value.toLowerCase() === 'tvshows') {
viewType.value = 'Series'
}
console.log('ItemType =>', viewType)
const data = ref<FeedItem[]>()
console.log(viewType)
data.value = await Items(viewId, viewType.value)

@ -1,31 +1,52 @@
<script setup lang="ts">
import type { FeedItem } from '@/feed'
import jfapi, { type Item } from '@/jfapi'
import { getDisplayDuration, ticksToMH } from '@/utils'
import { onBeforeMount, ref } from 'vue'
const props = defineProps<{
item: FeedItem
endsAt: boolean
itemId: string
endsAt?: boolean
size: string
}>()
const item = ref<Item>()
const timeString = ref('')
onBeforeMount(async () => {
const data = await jfapi.GetItem(props.itemId)
if (data) {
item.value = data
const now = new Date()
const runtime = ticksToMH(data.RunTimeTicks)
const endTime = new Date(now.getTime() + 60 * 1000 * (runtime.hours * 60 + runtime.minutes))
timeString.value = `${endTime.toLocaleString('default', { hour: 'numeric', minute: 'numeric' }).replace(' PM', 'pm').replace(' AM', 'am')}`
}
})
</script>
<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
class="info-misc"
:style="{ fontSize: `${props.size}px`, gap: `${size === 'sm' ? '1.4' : '3'}em` }"
>
<div class="misc-info-year">{{ item?.ProductionYear }}</div>
<div class="misc-info-runtime" v-if="item?.RunTimeTicks">
{{ getDisplayDuration(item?.RunTimeTicks) }}
</div>
<div class="misc-info imdb-rating" v-if="item?.CommunityRating">
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ item?.CommunityRating.toFixed(2) }}
</div>
<div class="misc-info-age-rating" v-if="item?.OfficialRating">
<span :class="`age-rating ${props.size ? props.size : ''}`">{{ item?.OfficialRating }}</span>
</div>
<div class="misc-info-age-rating"><span class="age-rating">R</span></div>
<div class="media-showcase-end-time" v-if="props.endsAt">Ends at 6:23pm</div>
<div class="media-showcase-end-time" v-if="props.endsAt">{{ `Ends at ${timeString}` }}</div>
</div>
</template>
<style scoped>
.info-misc {
display: flex;
justify-content: space-around;
gap: 3em;
justify-content: space-between;
color: var(--color-text);
font-size: 18px;
}
</style>

@ -28,19 +28,19 @@ const translateForward = () => {
if (props.landscape) {
translate.value -= 1008
} else {
translate.value -= 924
translate.value -= 1058
}
}
const translateBackward = () => {
if (props.landscape) {
translate.value += 1008
} else {
translate.value += 924
translate.value += 1058
}
}
const disableForward = () => {
const v =
(items.value ? items.value.length : 0) * (props.landscape ? 245 : 125) -
(items.value ? items.value.length : 0) * (props.landscape ? 317 : 185) -
Math.abs(translate.value) <
renderSpace
return v
@ -63,7 +63,7 @@ const disableForward = () => {
<div
class="scroller-content no-select"
:style="{
height: `${props.landscape ? 210 : 240}px`,
height: `${props.landscape ? 250 : 320}px`,
transform: `translateX(${translate}px)`,
}"
>

@ -7,13 +7,9 @@ import type { Item } from '@/jfapi'
const Items = async (): Promise<FeedItem[]> => {
return new Promise(async (resolve) => {
const feed: FeedItem[] = []
const userid = localStorage.getItem('jf_userid')
const data = await jfapi.GetNextUp(userid || '')
const data = await jfapi.GetNextUp()
if (data !== null) {
for (const item of data.Items) {
if (userid === null) {
window.location.href = '/login'
}
feed.push({
title: item.SeriesName !== undefined ? item.SeriesName : '',
subtext: `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`,

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { FeedItem } from '@/feed'
import MediaInfo from './MediaInfo.vue'
const props = defineProps<{
item: FeedItem
@ -10,18 +11,21 @@ const props = defineProps<{
<div
class="scroller-item"
:style="{
width: `${240}px`,
width: `${320}px`,
}"
>
<div class="scroller-item-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" />
<img
:src="props.item.image"
alt=""
:style="{
width: `${240}px`,
}"
/>
<RouterLink :to="`/play/${item.itemId}`">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon"
/></RouterLink>
<RouterLink :to="`/item/${item.itemId}`">
<img
:src="props.item.image"
alt=""
:style="{
width: `${320}px`,
}"
/></RouterLink>
<div class="info-box">
<div class="info-box-content">
<div class="info-box-header">
@ -34,18 +38,7 @@ const props = defineProps<{
<div class="info-box-subtext">{{ props.item.infoSubtext }}</div>
</div>
<div class="info-box-summary clamp-4">{{ props.item.summary }}</div>
<div class="info-box-misc-info">
<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.item.imdbRating }}
</div>
<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>
<MediaInfo :item-id="item.itemId" size="sm" />
</div>
</div>
</div>
@ -54,10 +47,10 @@ const props = defineProps<{
class="scroller-item-description"
:key="props.item.itemId"
>
<div class="scroller-item-title clamp-2" :style="{ maxWidth: `${240 - 5}px` }">
<div class="scroller-item-title clamp-2" :style="{ maxWidth: `${320 - 5}px` }">
{{ props.item.title }}
</div>
<div class="scroller-item-subtext clamp" :style="{ maxWidth: `${240 - 5}px` }">
<div class="scroller-item-subtext clamp" :style="{ maxWidth: `${320 - 5}px` }">
{{ props.item.subtext }}
</div></RouterLink
>
@ -77,15 +70,15 @@ const props = defineProps<{
.play-icon {
position: absolute;
top: 45px;
left: 95px;
width: 35px;
height: 35px;
top: 65px;
left: 115px;
width: 36px;
height: 36px;
cursor: pointer;
z-index: 2;
opacity: 0%;
padding: 0.75em;
background: rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
@ -98,8 +91,8 @@ const props = defineProps<{
background: rgba(122, 122, 122, 0.2);
}
.scroller-item-img:hover .play-icon {
top: 65px;
left: 135px;
top: 98px;
left: 192px;
opacity: 100%;
transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay),
@ -118,7 +111,7 @@ const props = defineProps<{
transition-duration: 0.3s;
}
.scroller-item-img:hover img {
transform: scale(1.35) translateX(32px) translateY(17px);
transform: scale(1.35) translateX(42px) translateY(23px);
cursor: pointer;
transition:
all var(--hover-animation-speed) ease,
@ -140,37 +133,36 @@ const props = defineProps<{
top: 0;
left: 0;
z-index: 0;
width: 240px;
height: 135px;
width: 320px;
height: 180px;
visibility: hidden;
background-color: var(--color-background-grey);
transition-duration: 0.3s;
}
.scroller-item-img:hover .info-box {
visibility: visible;
transform: translateX(324px);
transform: translateX(432px);
width: 340px;
height: 183px;
height: 243px;
z-index: 3;
transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay),
z-index 1ms var(--hover-animation-delay);
}
.info-box-content {
width: 90%;
margin: 0 auto;
padding: 1em;
max-height: 135px;
padding: 1.5em;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: baseline;
justify-content: space-around;
justify-content: space-between;
gap: 1em;
min-height: 100%;
height: 100%;
font-size: 13px;
}
.scroller-item-img:hover .info-box-content {
max-height: 183px;
max-height: 243px;
transition-delay: 0.05s;
}
.info-box-title {

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FeedItem } from '@/feed'
import { RouterLink } from 'vue-router'
import MediaInfo from './MediaInfo.vue'
const props = defineProps<{
item: FeedItem
@ -17,42 +18,34 @@ const props = defineProps<{
</div>
</div>
<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: `${160}px`,
height: `${160 * (3 / 2)}px`,
}"
/>
<RouterLink :to="`/play/${item.itemId}`">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon"
/></RouterLink>
<RouterLink :to="`/item/${item.itemId}`">
<img
:src="props.item.image"
alt=""
:style="{
width: `${160}px`,
height: `${160 * (3 / 2)}px`,
}"
/></RouterLink>
</div>
<div class="info-box">
<div class="info-box-content">
<div class="info-box-header">
<RouterLink
class="info-box-title clamp"
class="info-box-title clamp-2"
:to="`/item/${props.item.itemId}`"
:key="props.item.itemId"
>{{ props.item.title }}</RouterLink
>{{ props.item.title }}
<div class="info-box-subtext clamp" v-show="item.infoSubtext !== ''">
{{ item.infoSubtext }}
</div></RouterLink
>
<div class="info-box-subtext" v-show="item.infoSubtext !== ''">
{{ item.infoSubtext }}
</div>
</div>
<div class="info-box-summary clamp-8">{{ props.item.summary }}</div>
<div class="info-box-misc-info">
<div class="date">{{ props.item.date }}</div>
<div class="runtime" v-if="props.item.runtime !== '1m'">{{ props.item.runtime }}</div>
<div class="imdb">
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" />
{{ props.item.imdbRating }}
</div>
<div class="mpaa" v-show="props.item.MPAA !== undefined">
<span class="age-rating sm">{{ props.item.MPAA }}</span>
</div>
</div>
<MediaInfo :itemId="item.itemId" size="sm" />
</div>
</div>
</div>
@ -82,7 +75,7 @@ const props = defineProps<{
z-index: 2;
opacity: 0%;
padding: 0.75em;
background: rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
@ -95,8 +88,8 @@ const props = defineProps<{
background: rgba(122, 122, 122, 0.2);
}
.scroller-item-img:hover .play-icon {
top: 50%;
left: 50%;
top: 53%;
left: 49%;
opacity: 100%;
transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay),
@ -180,17 +173,15 @@ const props = defineProps<{
z-index 1ms var(--hover-animation-delay);
}
.info-box-content {
width: 90%;
margin: 0 auto;
padding: 1em;
padding: 1.5em;
max-height: 180px;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: baseline;
justify-content: space-between;
gap: 1em;
min-height: 95%;
min-height: 100%;
font-size: 13px;
}
.scroller-item-img:hover .info-box-content {
@ -208,6 +199,7 @@ const props = defineProps<{
}
.info-box-subtext {
color: var(--color-text-faded);
font-size: 12px;
}
.info-box-misc-info {
display: flex;

@ -0,0 +1,5 @@
<template>
<div class="search-box">
<input type="text" name="" id="" />
</div>
</template>

@ -151,7 +151,10 @@ class JFApi {
})
}
request<T>(endpoint: Endpoint): Promise<T | null> {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
const host = localStorage.getItem('jf_host')
if (host === '' || host === null) {
window.location.href = '/login'
}
let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) {
deviceId = generateId()
@ -174,49 +177,76 @@ class JFApi {
})
})
}
LoadViews(userid: string): Promise<Records<View> | null> {
LoadViews(): Promise<Records<View> | null> {
const userId = localStorage.getItem('jf_userid') || ''
return this.request({
resource: `/UserViews?userId=${userid}`,
resource: `/UserViews?userId=${userId}`,
method: 'GET',
})
.then((data) => {
const views = data as Records<View>
const response: Records<View> = {
Items: [],
StartIndex: 0,
TotalRecordCount: views.TotalRecordCount,
}
for (const view of views.Items) {
if (view.CollectionType !== 'movies' && view.CollectionType !== 'tvshows') {
response.TotalRecordCount--
continue
}
response.Items.push(view)
}
return response
})
.catch(() => {
return null
})
}
GetLatest(userid: string, view: View): Promise<Item[] | null> {
GetLatest(view: View): Promise<Item[] | null> {
const userId = localStorage.getItem('jf_userid') || ''
return this.request({
resource: `/Users/${userid}/Items/Latest?Limit=16&Fields=PrimaryImageAspectRatio%2COverview%2CPath%2COriginalTitle&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(userId: string): Promise<Records<Item> | null> {
GetNextUp(): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
return this.request({
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',
})
}
GetNextUpInSeries(userId: string, seriesId: string): Promise<Records<Item> | null> {
GetNextUpInSeries(seriesId: string): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
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> {
GetSeasons(seriesId: string): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
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> {
GetEpisodes(seriesId: string, seasonId: string): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
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> {
GetFirstEpisode(seriesId: string): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
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> {
GetItem(itemId: string): Promise<Item | null> {
const userid = localStorage.getItem('jf_userid') || ''
const req = this.buildRequest({
resource: `/Items/${itemId}?userId=${userid}`,
method: 'GET',
@ -244,9 +274,17 @@ class JFApi {
})
})
}
GetItemsInView(userid: string, viewId: string, type: string, startIndex: number) {
GetItemsInView(viewId: string, type: string, startIndex: number) {
const userId = localStorage.getItem('jf_userid') || ''
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}`,
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',
})
}
SearchItems(query: string) {
const userId = localStorage.getItem('jf_userid') || ''
return this.request<Records<Item>>({
resource: `/Items?userId=${userId}&limit=100&recursive=true&searchTerm=${query}&fields=PrimaryImageAspectRatio&fields=MediaSourceCount&imageTypeLimit=1&enableTotalRecordCount=false`, // &includeItemTypes=Movie
method: 'GET',
})
}
@ -256,8 +294,8 @@ class JFApi {
method: 'GET',
})
}
PostProgress(userId: string, itemId: string, sessionId: string, ticks: number) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host')
PostProgress(itemId: string, sessionId: string, ticks: number) {
const host = localStorage.getItem('jf_host') || ''
fetch(`${host}/Sessions/Playing/Progress`, {
body: JSON.stringify({
CanSeek: true,

@ -2,6 +2,7 @@ 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 SearchMedia from '@/components/SearchMedia.vue'
import ItemView from '@/views/ItemView.vue'
import VideoView from '@/views/VideoView.vue'
@ -18,6 +19,10 @@ const router = createRouter({
path: 'home',
component: HomeMedia,
},
{
path: 'search',
component: SearchMedia,
},
{
path: 'view/:id',
component: MediaGrid,
@ -47,7 +52,7 @@ const router = createRouter({
},
],
meta: {
isVideoPlayer: true,
hideMenu: true,
},
},
{
@ -55,7 +60,7 @@ const router = createRouter({
name: 'login',
component: () => import(/* webpackChunkName: 'login' */ '@/views/LoginView.vue'),
meta: {
isLoginPage: true,
hideMenu: true,
},
},
],

@ -14,16 +14,24 @@ function getImageLink(item: Item): string {
}
}
function getDisplayDuration(ticks: number) {
function ticksToMH(ticks: number): { hours: number; minutes: number } {
const totalMinutes = Math.round(ticks / 600000000) || 1
const totalHours = Math.floor(totalMinutes / 60)
const remainderMinutes = totalMinutes % 60
return {
hours: totalHours,
minutes: remainderMinutes,
}
}
function getDisplayDuration(ticks: number) {
const runtime = ticksToMH(ticks)
const result = []
if (totalHours > 0) {
result.push(`${totalHours}h`)
if (runtime.hours > 0) {
result.push(`${runtime.hours}h`)
}
result.push(`${remainderMinutes}m`)
result.push(`${runtime.minutes}m`)
return result.join(' ')
}
export { getImageLink, getDisplayDuration }
export { getImageLink, getDisplayDuration, ticksToMH }

@ -8,30 +8,15 @@ import { useRoute } from 'vue-router'
const views = ref<View[]>()
onBeforeMount(async () => {
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)
viewlist.value = await jfapi.LoadViews()
if (viewlist.value === null) {
// error
} 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 : ''))
views.value = viewlist.value.Items
for (const view of views.value) {
localStorage.setItem('jf_view_' + view.Id, view.CollectionType)
}
}
console.log('Host =>', host)
if (window.location.pathname.toLowerCase() !== '/login') {
if (host === null) {
window.location.href = '/login'
} else if (viewlist.value === null) {
window.location.href = '/login'
}
}
console.log(views.value)
})
const route = useRoute()
@ -62,6 +47,9 @@ const viewIsActive = (id: string) => {
:class="viewIsActive(view.Id) ? 'active' : ''"
>{{ view.Name }}</RouterLink
>
<RouterLink to="/search">
<font-awesome-icon icon="fa-solid fa-magnifying-glass" size="sm" />
</RouterLink>
</div>
<div class="media">
<RouterView v-slot="{ Component, route }"
@ -81,15 +69,17 @@ a {
display: flex;
justify-content: center;
margin-top: 1.5em;
gap: 3em;
gap: 1em;
align-items: center;
}
.home-nav-item {
padding: 0.4em 1.5em;
}
.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);

@ -1,16 +1,15 @@
<script setup lang="ts">
import MediaShowcase from '@/components/MediaShowcase.vue'
import type { View, ViewList } from '@/jfapi'
import type { View, Records } from '@/jfapi'
import { onBeforeMount, ref } from 'vue'
import jfapi from '@/jfapi'
const views = ref<View[]>()
onBeforeMount(async () => {
const viewlist = ref<ViewList | null>()
const userid = localStorage.getItem('jf_userid')
const viewlist = ref<Records<View> | null>()
const host = localStorage.getItem('jf_host')
console.log('Host =>', host)
viewlist.value = await jfapi.LoadViews(userid ? userid : '')
viewlist.value = await jfapi.LoadViews()
if (window.location.pathname.toLowerCase() !== '/login') {
if (host === null) {
window.location.href = '/login'

@ -1,8 +1,8 @@
<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 jfapi, { type Item, type MediaStream } from '@/jfapi'
import { onBeforeMount, ref } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
const route = useRoute()
@ -12,70 +12,79 @@ const itemId = String(route.params.id)
const item = ref<Item>()
const directors = ref<string[]>([])
const writers = ref<string[]>([])
const selectSubTrack = ref('')
const selectAudioTrack = ref('')
const selectSubTrack = ref<MediaStream>()
const selectAudioTrack = ref<MediaStream>()
const nextUp = ref<Item>()
const numAudioStreams = ref(0)
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]
}
})
}
})
const VideoStream = ref<MediaStream>()
const AudioStreams = ref<MediaStream[]>([])
const SubtitleStreams = ref<MediaStream[]>([])
onBeforeMount(() => {
jfapi.GetItem(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(item.value.Id || '').then((data) => {
if (data?.Items && data.Items.length > 0) {
nextUp.value = data.Items[0]
} else {
jfapi.GetFirstEpisode(item.value?.Id || '').then((data) => {
if (data?.Items) {
nextUp.value = data.Items[0]
}
})
}
})
}
}
}
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
if (item.value !== undefined && item.value.MediaStreams !== undefined) {
for (const stream of item.value.MediaStreams) {
if (stream.Type.toLowerCase() === 'video') {
VideoStream.value = stream
} else if (stream.Type.toLowerCase() === 'audio') {
AudioStreams.value.push(stream)
numAudioStreams.value++
if (stream.IsForced && !selectAudioTrack.value?.DisplayTitle.includes('Forced')) {
selectAudioTrack.value = stream
} else if (
stream.IsDefault &&
!selectAudioTrack.value?.DisplayTitle.includes('Forced') &&
!selectAudioTrack.value?.DisplayTitle.includes('Default')
) {
selectAudioTrack.value = stream
}
} else if (stream.Type.toLowerCase() === 'subtitle') {
SubtitleStreams.value.push(stream)
if (stream.IsForced && !selectSubTrack.value?.DisplayTitle.includes('Forced')) {
selectSubTrack.value = stream
} else if (
stream.IsDefault &&
!selectSubTrack.value?.DisplayTitle.includes('Forced') &&
!selectSubTrack.value?.DisplayTitle.includes('Default')
) {
console.log(stream)
selectSubTrack.value = stream
}
}
}
}
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)
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)
console.log(writers.value, directors.value)
})
})
const arrayRange = (start: number, stop: number) =>
Array.from({ length: (stop - start) / 1 + 1 }, (value, index) => start + index * 1)
Array.from({ length: (stop - start) / 1 }, (value, index) => start + index * 1)
</script>
<template>
@ -105,12 +114,12 @@ const arrayRange = (start: number, stop: number) =>
:src="`${
item.ParentLogoImageTag && item.ParentLogoItemId
? jfapi.LogoImageUrl(item.ParentLogoItemId, item.ParentLogoImageTag)
: jfapi.LogoImageUrl(item.Id, item.ImageTags.Logo)
: jfapi.LogoImageUrl(item.Id, item.ImageTags.Logo || '')
}`"
alt=""
/>
<div class="title" v-else>{{ item.SeriesName ? item.SeriesName : item.Name }}</div>
<MediaInfo />
<MediaInfo :itemId="item.Id" :endsAt="item.Type !== 'Series'" size="lg" />
</div>
</div>
<div class="info-wrapper">
@ -135,22 +144,22 @@ const arrayRange = (start: number, stop: number) =>
<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 class="stream-value">{{ VideoStream?.DisplayTitle }}</div>
</div>
<div class="info-item">
<div class="stream-type">Audio</div>
<div class="stream-selector" v-if="numAudioStreams > 1">
<div class="stream-selector" v-if="AudioStreams.length > 1">
<select name="audio-stream-select" :id="`${item?.Id}-audio-select`">
<option
:value="`${item?.MediaStreams}`"
v-for="i in arrayRange(1, numAudioStreams)"
:value="`${AudioStreams[i].DisplayTitle}`"
v-for="i in arrayRange(0, AudioStreams.length)"
:key="`audio-stream-${i}`"
>
{{ item?.MediaStreams[i].DisplayTitle }}
{{ AudioStreams[i].DisplayTitle }}
</option>
</select>
</div>
<div class="stream-value" v-else>{{ item?.MediaStreams[1].DisplayTitle }}</div>
<div class="stream-value" v-else>{{ AudioStreams[0].DisplayTitle }}</div>
</div>
<div class="info-item">
<div class="stream-type">Subtitles</div>
@ -158,16 +167,16 @@ const arrayRange = (start: number, stop: number) =>
<select
name="subtitle-stream-select"
:id="`${item?.Id}-subtitle-select`"
v-if="item?.MediaStreams.length > numAudioStreams + 1"
v-if="SubtitleStreams.length > 0"
>
<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"
v-for="i in arrayRange(0, SubtitleStreams.length)"
:value="`${SubtitleStreams[i]?.DisplayTitle}`"
:selected="SubtitleStreams[i]?.DisplayTitle === selectSubTrack?.DisplayTitle"
:key="`subtitle-stream-${i}`"
>
{{ item?.MediaStreams[i].DisplayTitle }}
{{ SubtitleStreams[i]?.DisplayTitle }}
</option>
</select>
<div class="stream-value" v-else>None</div>
@ -175,6 +184,9 @@ const arrayRange = (start: number, stop: number) =>
</div>
</div>
</div>
<div class="title episode-title" v-if="item.Type === 'Episode'">
{{ `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` }}
</div>
<blockquote class="tagline" v-if="item?.Taglines.length > 0">
{{ item?.Taglines[0] }}
</blockquote>
@ -218,6 +230,9 @@ const arrayRange = (start: number, stop: number) =>
margin: auto;
padding: 3em 0;
}
.subheading {
width: 70%;
}
.item-info {
padding: 3em 4em;
width: 800px;
@ -238,8 +253,7 @@ const arrayRange = (start: number, stop: number) =>
justify-content: space-between;
gap: 1.5em;
width: 95%;
margin: 0 auto;
margin-top: 2em;
margin: 2em auto;
}
.media-title {
min-height: 200px;
@ -252,6 +266,13 @@ const arrayRange = (start: number, stop: number) =>
font-size: 52px;
font-weight: bold;
font-family: 'DM Sans', sans-serif;
text-align: center;
width: 90%;
}
.title.episode-title {
font-size: 24px;
font-weight: 500;
text-align: left;
}
.logo {
display: flex;

Loading…
Cancel
Save