chores + MediaInfo progress

main
Gabe Farrell 1 year ago
parent 7e7aa8f6f4
commit a93426ded2

@ -1,21 +1,28 @@
# featherfin # featherfin
### TODO ## TODO
- [ ] Add episode title and SXXEXX to item screen for episode ### Features
- [ ] Enlarge Next Up section on Home
- [ ] Fix row spacing on Home - [x] Add episode title and SXXEXX to item screen for episode
- [ ] Add Continue Watching section to Home - [ ] 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 - [ ] Finish Item page
- [ ] Add Actor List to all items except seasons - [ ] Add Actor List to all items except seasons
- [ ] Add episode list for series/seasons - [ ] Add episode list for series/seasons
- [ ] Add next episode list for episode - [ ] Add next episode list for episode
- [ ] Add related shows/movies to series and movies - [ ] Add related shows/movies to series and movies
- [ ] Finish implementing MediaInfo - [ ] Don't assume stream order
- [ ] Move all MediaInfo stand-ins to the component - [x] Finish implementing MediaInfo
- [ ] Make Like and Favorite buttons work - [ ] Make Like and Favorite buttons work
- [ ] Show state - [ ] Show state
- [ ] Update 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 - [ ] Make play buttons on media start playing
- [ ] Movies start playing movie - [ ] Movies start playing movie
- [ ] Episodes play episode - [ ] Episodes play episode
@ -24,5 +31,18 @@
- [ ] Design UI elements - [ ] Design UI elements
- [ ] Implement - [ ] Implement
- [ ] Add search - [ ] Add search
- [ ] Add search page
- [ ] Add search functionality
- [ ] Make featured media carousel work - [ ] Make featured media carousel work
- [ ] a lot - [ ] 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> </script>
<template> <template>
<OnClickOutside @trigger="menuOpen = false"> <OnClickOutside @trigger="menuOpen = false">
<div <div class="primary-nav no-select" v-if="!$route.meta.hideMenu">
class="primary-nav no-select"
v-if="!$route.meta.isLoginPage && !$route.meta.isVideoPlayer"
>
<div class="menu-toggle" @click="menuOpen = !menuOpen"> <div class="menu-toggle" @click="menuOpen = !menuOpen">
<font-awesome-icon icon="fa-solid fa-bars" size="xl" /> <font-awesome-icon icon="fa-solid fa-bars" size="xl" />
</div> </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"> <script setup lang="ts">
import type { View } from '@/jfapi' import type { Records, View } from '@/jfapi'
import NextUp from './NextUp.vue' import NextUp from './NextUp.vue'
import LatestMedia from './LatestMedia.vue' import LatestMedia from './LatestMedia.vue'
import { onBeforeMount, ref } from 'vue' import { onBeforeMount, ref } from 'vue'
import jfapi from '@/jfapi' import jfapi from '@/jfapi'
const views = ref<View[]>() const views = ref<View[]>()
const viewstring = ref<string | null>('') onBeforeMount(async () => {
views.value = undefined const viewlist = ref<Records<View> | null>()
onBeforeMount(() => { viewlist.value = await jfapi.LoadViews()
viewstring.value = localStorage.getItem('jf_views') if (viewlist.value === null) {
views.value = JSON.parse(viewstring.value) // error
} else {
views.value = viewlist.value.Items
}
console.log(views.value)
}) })
</script> </script>
<template> <template>
<NextUp /> <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" /> <LatestMedia v-for="view of views" :view="view" :key="view.Id" />
</div> </div>
</template> </template>

@ -12,14 +12,10 @@ const props = defineProps<{
const Items = async (): Promise<FeedItem[]> => { const Items = async (): Promise<FeedItem[]> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
const feed: FeedItem[] = [] const feed: FeedItem[] = []
const userid = localStorage.getItem('jf_userid') const data = await jfapi.GetLatest(props.view)
const data = await jfapi.GetLatest(userid ? userid : '', props.view)
if (data !== null) { if (data !== null) {
console.log(data) console.log(data)
for (const item of data) { for (const item of data) {
if (userid === null) {
window.location.href = '/login'
}
feed.push({ feed.push({
title: item.SeriesName || item.Name, title: item.SeriesName || item.Name,
subtext: item.SeriesName subtext: item.SeriesName

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

@ -1,31 +1,52 @@
<script setup lang="ts"> <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<{ const props = defineProps<{
item: FeedItem itemId: string
endsAt: boolean endsAt?: boolean
size: string 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> </script>
<template> <template>
<div class="info-misc"> <div
<div class="misc-info-year">2019</div> class="info-misc"
<div class="misc-info-runtime">2h 12m</div> :style="{ fontSize: `${props.size}px`, gap: `${size === 'sm' ? '1.4' : '3'}em` }"
<div class="misc-info imdb-rating"> >
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" /> 7.6 <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>
<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 ${timeString}` }}</div>
<div class="media-showcase-end-time" v-if="props.endsAt">Ends at 6:23pm</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.info-misc { .info-misc {
display: flex; display: flex;
justify-content: space-around; justify-content: space-between;
gap: 3em;
color: var(--color-text); color: var(--color-text);
font-size: 18px;
} }
</style> </style>

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

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

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

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FeedItem } from '@/feed' import type { FeedItem } from '@/feed'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import MediaInfo from './MediaInfo.vue'
const props = defineProps<{ const props = defineProps<{
item: FeedItem item: FeedItem
@ -17,42 +18,34 @@ const props = defineProps<{
</div> </div>
</div> </div>
<div class="poster-img"> <div class="poster-img">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon" /> <RouterLink :to="`/play/${item.itemId}`">
<font-awesome-icon icon="fa-solid fa-play" class="play-icon"
/></RouterLink>
<RouterLink :to="`/item/${item.itemId}`">
<img <img
loading="lazy"
:src="props.item.image" :src="props.item.image"
alt="" alt=""
:style="{ :style="{
width: `${160}px`, width: `${160}px`,
height: `${160 * (3 / 2)}px`, height: `${160 * (3 / 2)}px`,
}" }"
/> /></RouterLink>
</div> </div>
<div class="info-box"> <div class="info-box">
<div class="info-box-content"> <div class="info-box-content">
<div class="info-box-header"> <div class="info-box-header">
<RouterLink <RouterLink
class="info-box-title clamp" class="info-box-title clamp-2"
:to="`/item/${props.item.itemId}`" :to="`/item/${props.item.itemId}`"
:key="props.item.itemId" :key="props.item.itemId"
>{{ props.item.title }}</RouterLink >{{ props.item.title }}
> <div class="info-box-subtext clamp" v-show="item.infoSubtext !== ''">
<div class="info-box-subtext" v-show="item.infoSubtext !== ''">
{{ item.infoSubtext }} {{ item.infoSubtext }}
</div> </div></RouterLink
>
</div> </div>
<div class="info-box-summary clamp-8">{{ props.item.summary }}</div> <div class="info-box-summary clamp-8">{{ props.item.summary }}</div>
<div class="info-box-misc-info"> <MediaInfo :itemId="item.itemId" size="sm" />
<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>
</div> </div>
</div> </div>
</div> </div>
@ -82,7 +75,7 @@ const props = defineProps<{
z-index: 2; z-index: 2;
opacity: 0%; opacity: 0%;
padding: 0.75em; padding: 0.75em;
background: rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 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); background: rgba(122, 122, 122, 0.2);
} }
.scroller-item-img:hover .play-icon { .scroller-item-img:hover .play-icon {
top: 50%; top: 53%;
left: 50%; left: 49%;
opacity: 100%; opacity: 100%;
transition: transition:
all var(--hover-animation-speed) ease var(--hover-animation-delay), all var(--hover-animation-speed) ease var(--hover-animation-delay),
@ -180,17 +173,15 @@ const props = defineProps<{
z-index 1ms var(--hover-animation-delay); z-index 1ms var(--hover-animation-delay);
} }
.info-box-content { .info-box-content {
width: 90%;
margin: 0 auto; margin: 0 auto;
padding: 1em; padding: 1.5em;
max-height: 180px; max-height: 180px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 1em; min-height: 100%;
min-height: 95%;
font-size: 13px; font-size: 13px;
} }
.scroller-item-img:hover .info-box-content { .scroller-item-img:hover .info-box-content {
@ -208,6 +199,7 @@ const props = defineProps<{
} }
.info-box-subtext { .info-box-subtext {
color: var(--color-text-faded); color: var(--color-text-faded);
font-size: 12px;
} }
.info-box-misc-info { .info-box-misc-info {
display: flex; 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> { 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') let deviceId = localStorage.getItem('jf_deviceId')
if (deviceId === null) { if (deviceId === null) {
deviceId = generateId() 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({ return this.request({
resource: `/UserViews?userId=${userid}`, resource: `/UserViews?userId=${userId}`,
method: 'GET', 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({ 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', method: 'GET',
}) })
} }
GetNextUp(userId: string): Promise<Records<Item> | null> { GetNextUp(): Promise<Records<Item> | null> {
const userId = localStorage.getItem('jf_userid') || ''
return this.request({ 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`, 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', 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({ return this.request({
resource: `/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount%2COverview%2COriginalTitle`, resource: `/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount%2COverview%2COriginalTitle`,
method: 'GET', 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({ return this.request({
resource: `/Shows/${seriesId}/Seasons?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount`, resource: `/Shows/${seriesId}/Seasons?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount`,
method: 'GET', 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({ return this.request({
resource: `/Shows/${seriesId}/Episodes?seasonId=${seasonId}&userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle`, resource: `/Shows/${seriesId}/Episodes?seasonId=${seasonId}&userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle`,
method: 'GET', 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({ return this.request({
resource: `/Shows/${seriesId}/Episodes?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle&limit=1`, resource: `/Shows/${seriesId}/Episodes?userId=${userId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CMediaSourceCount%2COverview%2COriginalTitle&limit=1`,
method: 'GET', 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({ const req = this.buildRequest({
resource: `/Items/${itemId}?userId=${userid}`, resource: `/Items/${itemId}?userId=${userid}`,
method: 'GET', 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>>({ 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', method: 'GET',
}) })
} }
@ -256,8 +294,8 @@ class JFApi {
method: 'GET', method: 'GET',
}) })
} }
PostProgress(userId: string, itemId: string, sessionId: string, ticks: number) { PostProgress(itemId: string, sessionId: string, ticks: number) {
const host = localStorage.getItem('jf_host') === null ? '' : localStorage.getItem('jf_host') const host = localStorage.getItem('jf_host') || ''
fetch(`${host}/Sessions/Playing/Progress`, { fetch(`${host}/Sessions/Playing/Progress`, {
body: JSON.stringify({ body: JSON.stringify({
CanSeek: true, CanSeek: true,

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import BrowseView from '@/views/BrowseView.vue' import BrowseView from '@/views/BrowseView.vue'
import HomeMedia from '@/components/HomeMedia.vue' import HomeMedia from '@/components/HomeMedia.vue'
import MediaGrid from '@/components/MediaGrid.vue' import MediaGrid from '@/components/MediaGrid.vue'
import SearchMedia from '@/components/SearchMedia.vue'
import ItemView from '@/views/ItemView.vue' import ItemView from '@/views/ItemView.vue'
import VideoView from '@/views/VideoView.vue' import VideoView from '@/views/VideoView.vue'
@ -18,6 +19,10 @@ const router = createRouter({
path: 'home', path: 'home',
component: HomeMedia, component: HomeMedia,
}, },
{
path: 'search',
component: SearchMedia,
},
{ {
path: 'view/:id', path: 'view/:id',
component: MediaGrid, component: MediaGrid,
@ -47,7 +52,7 @@ const router = createRouter({
}, },
], ],
meta: { meta: {
isVideoPlayer: true, hideMenu: true,
}, },
}, },
{ {
@ -55,7 +60,7 @@ const router = createRouter({
name: 'login', name: 'login',
component: () => import(/* webpackChunkName: 'login' */ '@/views/LoginView.vue'), component: () => import(/* webpackChunkName: 'login' */ '@/views/LoginView.vue'),
meta: { 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 totalMinutes = Math.round(ticks / 600000000) || 1
const totalHours = Math.floor(totalMinutes / 60) const totalHours = Math.floor(totalMinutes / 60)
const remainderMinutes = totalMinutes % 60 const remainderMinutes = totalMinutes % 60
return {
hours: totalHours,
minutes: remainderMinutes,
}
}
function getDisplayDuration(ticks: number) {
const runtime = ticksToMH(ticks)
const result = [] const result = []
if (totalHours > 0) { if (runtime.hours > 0) {
result.push(`${totalHours}h`) result.push(`${runtime.hours}h`)
} }
result.push(`${remainderMinutes}m`) result.push(`${runtime.minutes}m`)
return result.join(' ') return result.join(' ')
} }
export { getImageLink, getDisplayDuration } export { getImageLink, getDisplayDuration, ticksToMH }

@ -8,30 +8,15 @@ import { useRoute } from 'vue-router'
const views = ref<View[]>() const views = ref<View[]>()
onBeforeMount(async () => { onBeforeMount(async () => {
const viewlist = ref<Records<View> | null>() const viewlist = ref<Records<View> | null>()
const userid = localStorage.getItem('jf_userid') viewlist.value = await jfapi.LoadViews()
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) { if (viewlist.value === null) {
// error // error
} else { } else {
views.value = viewlist.value.Items views.value = viewlist.value.Items
localStorage.setItem('jf_views', JSON.stringify(viewlist.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() const route = useRoute()
@ -62,6 +47,9 @@ const viewIsActive = (id: string) => {
:class="viewIsActive(view.Id) ? 'active' : ''" :class="viewIsActive(view.Id) ? 'active' : ''"
>{{ view.Name }}</RouterLink >{{ view.Name }}</RouterLink
> >
<RouterLink to="/search">
<font-awesome-icon icon="fa-solid fa-magnifying-glass" size="sm" />
</RouterLink>
</div> </div>
<div class="media"> <div class="media">
<RouterView v-slot="{ Component, route }" <RouterView v-slot="{ Component, route }"
@ -81,15 +69,17 @@ a {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 1.5em; margin-top: 1.5em;
gap: 3em; gap: 1em;
align-items: center; align-items: center;
} }
.home-nav-item {
padding: 0.4em 1.5em;
}
.home-nav-item:hover { .home-nav-item:hover {
cursor: pointer; cursor: pointer;
color: var(--color-text-faded); color: var(--color-text-faded);
} }
.home-nav-item.active { .home-nav-item.active {
padding: 0.4em 1.5em;
background-color: var(--color-text); background-color: var(--color-text);
border-radius: 15px; border-radius: 15px;
color: var(--color-text-dark); color: var(--color-text-dark);

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

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

Loading…
Cancel
Save