parent
599560ca7d
commit
1cd1584bb3
@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { FeedItem } from '@/feed'
|
||||
import jfapi, { type View } from '@/jfapi'
|
||||
import { getImageLink, getDisplayDuration } from './utils'
|
||||
import { onBeforeMount, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ScrollerItemPortrait from './ScrollerItemPortrait.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const viewId = String(route.params.id)
|
||||
|
||||
console.log(viewId)
|
||||
|
||||
const Items = async (viewId: string, type: string): Promise<FeedItem[]> => {
|
||||
return new Promise(async (resolve) => {
|
||||
const feed: FeedItem[] = []
|
||||
const userid = localStorage.getItem('jf_userid')
|
||||
const data = await jfapi.GetItemsInView(userid ? userid : '', viewId, type)
|
||||
if (data !== null) {
|
||||
console.log(data)
|
||||
for (const item of data.Items) {
|
||||
const summary = ''
|
||||
if (userid === null) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
feed.push({
|
||||
title: item.SeriesName || item.Name,
|
||||
subtext: item.SeriesName
|
||||
? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}`
|
||||
: String(item.ProductionYear),
|
||||
image: getImageLink(item),
|
||||
summary: summary,
|
||||
infoSubtext: '',
|
||||
imdbRating: String(item.CommunityRating),
|
||||
runtime: getDisplayDuration(item.RunTimeTicks),
|
||||
date: String(item.ProductionYear),
|
||||
MPAA: item.OfficialRating,
|
||||
tag:
|
||||
item.UserData?.UnplayedItemCount !== undefined
|
||||
? String(item.UserData.UnplayedItemCount)
|
||||
: '',
|
||||
itemId: item.Id,
|
||||
})
|
||||
}
|
||||
console.log('MediaGrid.vue =>', feed)
|
||||
resolve(feed)
|
||||
}
|
||||
})
|
||||
}
|
||||
const items = ref<FeedItem[]>()
|
||||
const views = ref<View[]>()
|
||||
const viewstring = ref<string | null>('')
|
||||
views.value = undefined
|
||||
onBeforeMount(async () => {
|
||||
viewstring.value = localStorage.getItem('jf_views')
|
||||
views.value = JSON.parse(viewstring.value ? viewstring.value : '')
|
||||
let viewType = ''
|
||||
for (const view of views.value) {
|
||||
if (view.Id === viewId) {
|
||||
if (view.CollectionType.toLowerCase() === 'movies') {
|
||||
viewType = 'Movie'
|
||||
} else if (view.CollectionType.toLowerCase() === 'tvshows') {
|
||||
viewType = 'Series'
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('ItemType =>', viewType)
|
||||
const data = ref<FeedItem[]>()
|
||||
console.log(viewType)
|
||||
data.value = await Items(viewId, viewType)
|
||||
items.value = data.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-grid">
|
||||
<div class="grid-wrapper">
|
||||
<ScrollerItemPortrait
|
||||
v-for="item in items"
|
||||
:title="item.title"
|
||||
:subtext="item.subtext"
|
||||
:image="item.image"
|
||||
:info-subtext="item.infoSubtext"
|
||||
:date="item.date"
|
||||
:runtime="item.runtime"
|
||||
:summary="item.summary"
|
||||
:-m-p-a-a="item.MPAA"
|
||||
:imdb-rating="item.imdbRating"
|
||||
:key="item.title"
|
||||
:itemId="item.itemId"
|
||||
:tag="item.tag"
|
||||
class="scroller-item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-grid {
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
margin-top: 3em;
|
||||
}
|
||||
.grid-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="info-misc">
|
||||
<div class="misc-info-year">2019</div>
|
||||
<div class="misc-info-runtime">2h 12m</div>
|
||||
<div class="misc-info imdb-rating">
|
||||
<font-awesome-icon icon="fa-solid fa-star" size="xs" class="star-rating" /> 7.6
|
||||
</div>
|
||||
<div class="misc-info-age-rating"><span class="age-rating">R</span></div>
|
||||
<div class="media-showcase-end-time">Ends at 6:23pm</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-misc {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 3em;
|
||||
color: var(--color-text);
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,29 @@
|
||||
import jfapi from '@/jfapi'
|
||||
import type { Item } from '@/jfapi'
|
||||
|
||||
function getImageLink(item: Item): string {
|
||||
if (item.SeriesId === undefined) {
|
||||
// using movie primary
|
||||
return jfapi.PrimaryImageUrl(item.Id, item.ImageTags.Primary ? item.ImageTags.Primary : '')
|
||||
} else {
|
||||
// using series primary
|
||||
return jfapi.PrimaryImageUrl(
|
||||
item.SeriesId,
|
||||
item.SeriesPrimaryImageTag ? item.SeriesPrimaryImageTag[0] : '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayDuration(ticks: number) {
|
||||
const totalMinutes = Math.round(ticks / 600000000) || 1
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const remainderMinutes = totalMinutes % 60
|
||||
const result = []
|
||||
if (totalHours > 0) {
|
||||
result.push(`${totalHours}h`)
|
||||
}
|
||||
result.push(`${remainderMinutes}m`)
|
||||
return result.join(' ')
|
||||
}
|
||||
|
||||
export { getImageLink, getDisplayDuration }
|
||||
@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import MediaInfo from '@/components/MediaInfo.vue'
|
||||
import jfapi, { type Item } from '@/jfapi'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const itemId = String(route.params.id)
|
||||
|
||||
const item = ref<Item>()
|
||||
const directors = ref<string[]>([])
|
||||
const writers = ref<string[]>([])
|
||||
// const studios = ref<string[]>()
|
||||
const numAudioStreams = ref(0)
|
||||
const userid = localStorage.getItem('jf_userid')
|
||||
jfapi.GetItem(userid ? userid : '', itemId).then((data) => {
|
||||
if (data === null) {
|
||||
// error
|
||||
} else {
|
||||
item.value = data
|
||||
}
|
||||
if (item.value !== undefined) {
|
||||
for (const stream of item.value.MediaStreams) {
|
||||
if (stream.Type.toLowerCase() === 'audio') {
|
||||
numAudioStreams.value++
|
||||
}
|
||||
}
|
||||
for (const person of item.value.People) {
|
||||
if (person.Type.toLowerCase() === 'director') {
|
||||
directors.value.push(person.Name)
|
||||
} else if (person.Type.toLowerCase() === 'writer') {
|
||||
writers.value.push(person.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(writers.value, directors.value)
|
||||
})
|
||||
const arrayRange = (start: number, stop: number) =>
|
||||
Array.from({ length: (stop - start) / 1 + 1 }, (value, index) => start + index * 1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="container"
|
||||
:style="{
|
||||
backgroundAttachment: 'fixed',
|
||||
backgroundSize: 'cover',
|
||||
backgroundImage: `url(${jfapi.FullBackdropImageUrl(item ? item.Id : '', item ? item.BackdropImageTags[0] : '', 0)})`,
|
||||
}"
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
<div class="item-info">
|
||||
<div class="logo">
|
||||
<img :src="`${jfapi.LogoImageUrl(item.Id, item.ImageTags.Logo)}`" alt="" />
|
||||
<MediaInfo />
|
||||
</div>
|
||||
<div class="info-wrapper">
|
||||
<div class="section">
|
||||
<div class="detail-buttons">
|
||||
<div class="play-button">
|
||||
<button class="primary-button">
|
||||
<font-awesome-icon icon="fa-solid fa-play" size="md" />Play Now
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-buttons">
|
||||
<div class="watched">
|
||||
<font-awesome-icon icon="fa-solid fa-check" size="xl" class="clickable" />
|
||||
</div>
|
||||
<div class="favorited">
|
||||
<font-awesome-icon :icon="['far', 'heart']" size="xl" class="clickable" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-streams">
|
||||
<div class="stream-type">Video</div>
|
||||
<div class="stream-value">{{ item?.MediaStreams[0].DisplayTitle }}</div>
|
||||
<div class="stream-type">Audio</div>
|
||||
<div class="stream-selector" v-if="numAudioStreams > 1">
|
||||
<select name="audio-stream-select" :id="`${item?.Id}-audio-select`">
|
||||
<option
|
||||
:value="`${item?.MediaStreams}`"
|
||||
v-for="i in arrayRange(1, numAudioStreams)"
|
||||
:key="`audio-stream-${i}`"
|
||||
>
|
||||
{{ item?.MediaStreams[i].DisplayTitle }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stream-value" v-else>{{ item?.MediaStreams[1].DisplayTitle }}</div>
|
||||
<div class="stream-type">Subtitles</div>
|
||||
<div class="stream-selector">
|
||||
<select
|
||||
name="subtitle-stream-select"
|
||||
:id="`${item?.Id}-subtitle-select`"
|
||||
v-if="item?.MediaStreams.length > numAudioStreams + 1"
|
||||
>
|
||||
<option value="Off">Off</option>
|
||||
<option
|
||||
:value="`${item?.MediaStreams}`"
|
||||
v-for="i in arrayRange(numAudioStreams + 1, item.MediaStreams.length - 1)"
|
||||
:key="`subtitle-stream-${i}`"
|
||||
>
|
||||
{{ item?.MediaStreams[i].DisplayTitle }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="stream-value" v-else>None</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote class="tagline" v-if="item?.Taglines.length > 0">
|
||||
{{ item?.Taglines[0] }}
|
||||
</blockquote>
|
||||
<div class="overview">
|
||||
{{ item?.Overview }}
|
||||
</div>
|
||||
<div class="misc-info">
|
||||
<div class="info-type">Genre</div>
|
||||
<div class="info-value">{{ item?.Genres.join(', ') }}</div>
|
||||
<div class="info-type">{{ 'Director' + (directors.length > 1 ? 's' : '') }}</div>
|
||||
<div class="info-value">{{ directors.join(', ') }}</div>
|
||||
<div class="info-type">{{ 'Writer' + (writers.length > 1 ? 's' : '') }}</div>
|
||||
<div class="info-value">{{ writers.join(', ') }}</div>
|
||||
<div class="info-type">{{ 'Studio' + (item?.Studios.length > 1 ? 's' : '') }}</div>
|
||||
<div class="info-value">{{ item?.Studios.map((s) => s.Name).join(', ') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.content-wrapper {
|
||||
width: 95%;
|
||||
margin: auto;
|
||||
padding-top: 3em;
|
||||
}
|
||||
.item-info {
|
||||
padding: 3em 5em;
|
||||
width: 900px;
|
||||
min-height: 80vh;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 3em;
|
||||
width: 95%;
|
||||
margin: 0 auto;
|
||||
margin-top: 3em;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.logo img {
|
||||
max-height: 230px;
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.detail-buttons {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
margin-left: 2em;
|
||||
}
|
||||
.user-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 2em;
|
||||
}
|
||||
.media-streams {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 4fr;
|
||||
}
|
||||
.stream-type {
|
||||
text-align: right;
|
||||
padding: 0.2em;
|
||||
margin-right: 0.3em;
|
||||
color: var(--color-text-faded);
|
||||
}
|
||||
.stream-value {
|
||||
padding: 0.2em;
|
||||
}
|
||||
.stream-selector {
|
||||
margin: 0.2em;
|
||||
}
|
||||
.stream-selector select {
|
||||
width: 100%;
|
||||
}
|
||||
.misc-info {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 4fr;
|
||||
}
|
||||
.misc-info .info-type {
|
||||
text-align: right;
|
||||
margin-right: 0.5em;
|
||||
color: var(--color-text-faded);
|
||||
}
|
||||
.tagline {
|
||||
position: relative;
|
||||
}
|
||||
.tagline::before {
|
||||
content: '\201C';
|
||||
font-size: 156px;
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
left: -20px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
z-index: -1;
|
||||
font-family: 'Georgia', Times, serif;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue