Showing chapter information.

This commit is contained in:
DebaucheryLibrarian 2024-10-15 02:58:46 +02:00
parent 63b1198fca
commit 3aedfbf2d1
4 changed files with 281 additions and 3 deletions

View File

@ -49,12 +49,12 @@
</div>
<div
v-if="release.photos.length > 0 || release.caps.length > 0 || coversInAlbum"
v-if="release.photos.length > 0 || release.caps.length > 0 || release.chapters.some((chapter) => chapter.poster) || coversInAlbum"
class="album"
:class="{ single: (release.photos.length + release.caps.length) === 1 }"
:class="{ single: (release.photos.length + release.caps.length + release.chapters.filter((chapter) => chapter.poster).length) === 1 }"
>
<div
v-for="photo in [...(coversInAlbum ? release.covers : []), ...release.photos, ...release.caps]"
v-for="photo in [...(coversInAlbum ? release.covers : []), ...release.photos, ...release.caps, ...release.chapters.map((chapter) => chapter.poster).filter(Boolean)]"
:key="`photo-${photo.id}`"
class="photo-container"
>

239
components/scenes/chapters.vue Executable file
View File

@ -0,0 +1,239 @@
<template>
<div
v-if="timeline"
class="timeline"
>
<ul class="timeline-items nolist">
<li
v-for="chapter in timeline"
:key="`chapter-${chapter.id}`"
:style="{ left: `${(chapter.time / duration) * 100}%` }"
:title="formatDuration(chapter.time)"
class="timeline-item"
><a
:href="`/tag/${chapter.tags[0].slug}`"
class="link"
>{{ chapter.tags[0]?.name || '&nbsp;' }}</a></li>
</ul>
</div>
<ul
v-else
class="chapters nolist"
>
<li
v-for="chapter in chapters"
:key="`chapter-${chapter.id}`"
class="chapter"
>
<img
:src="getPath(chapter.poster, 'thumbnail')"
:style="{ 'background-image': `url('${getPath(chapter.poster, 'lazy')}'` }"
loading="lazy"
class="chapter-poster"
>
<span class="chapter-details">
<span
v-if="typeof chapter.time === 'number'"
v-tooltip="'Time in video'"
class="chapter-time"
><Icon icon="film3" /> {{ formatDuration(chapter.time) }}</span>
<span
v-if="chapter.duration"
v-tooltip="'Duration'"
class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
</span>
<div class="chapter-info">
<h3
v-if="chapter.title"
class="chapter-row chapter-title"
:title="chapter.title"
>{{ chapter.title }}</h3>
<p
v-if="chapter.description"
class="chapter-row chapter-description"
>{{ chapter.description }}</p>
<ul class="chapter-tags chapter-row nolist">
<li
v-for="tag in chapter.tags"
:key="`chapter-tag-${tag.slug}`"
class="chapter-tag"
>
<a
:href="`/tag/${tag.slug}`"
class="link"
>{{ tag.name }}</a>
</li>
</ul>
</div>
</li>
</ul>
</template>
<script setup>
import { computed } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDuration } from '#/utils/format.js';
const props = defineProps({
chapters: {
type: Array,
default: () => [],
},
});
const lastChapter = props.chapters.at(-1);
const duration = lastChapter.time + lastChapter.duration;
const timeline = computed(() => {
if (props.chapters.every((chapter) => chapter.time)) {
return props.chapters.filter((chapter) => chapter.tags?.length > 0);
}
return null;
});
</script>
<style scoped>
.chapters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: .5rem;
}
.chapter {
display: flex;
flex-direction: column;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-30);
border-radius: .25rem;
margin: 0 0 .5rem 0;
font-size: 0;
overflow: hidden;
}
.chapter-poster {
width: 100%;
height: 10rem;
object-fit: cover;
object-position: center;
}
.chapter-details {
height: 1.75rem;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 .5rem;
border-radius: 0 0 .25rem .25rem;
margin: 0 0 .5rem 0;
color: var(--text-light);
background: var(--grey-dark-40);
font-size: .8rem;
font-weight: bold;
.icon {
fill: var(--text-light);
margin-right: .5rem;
}
}
.chapter-duration,
.chapter-time {
display: flex;
align-items: center;
}
.chapter-duration .icon {
/* narrower icon */
margin: -.1rem .3rem 0 0;
}
.chapter-info {
padding: 0 .5rem;
font-size: 1rem;
}
.chapter-row {
margin: 0 0 .5rem 0;
}
.chapter-title {
padding: 0;
font-size: .9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-description {
line-height: 1.5;
}
.chapter-tags {
overflow: hidden;
}
.chapter-tag {
margin: 0 .5rem .25rem 0;
font-size: .75rem;
color: var(--glass-strong-10);
.link {
color: inherit;
text-decoration: none;
}
&:hover {
color: var(--primary);
}
}
.timeline-items {
position: relative;
height: 5rem;
border-bottom: solid 1px var(--shadow-weak);
}
.timeline-item {
position: absolute;
bottom: -.25rem;
padding: .1rem .5rem;
border-radius: .6rem;
color: var(--primary);
background: var(--background);
transform: rotate(-60deg);
transform-origin: 0 50%;
box-shadow: 0 0 3px var(--shadow-weak);
font-size: .8rem;
font-weight: bold;
.link {
color: inherit;
}
&:before {
content: '';
display: inline-block;
width: 1rem;
height: 2px;
position: absolute;
left: calc(-1rem + 1px);
margin: .3rem .5rem 0 0;
background: var(--primary);
}
}
@media(--small-30) {
.chapters {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
}
</style>

View File

@ -168,6 +168,12 @@
</div>
</div>
<Chapters
v-if="scene.chapters.length > 0"
:chapters="scene.chapters"
class="section"
/>
<div
v-if="scene.duration || scene.directors.length > 0 || scene.shootId || scene.qualities.length > 0"
class="section details"
@ -361,6 +367,7 @@ import Banner from '#/components/media/banner.vue';
import ActorTile from '#/components/actors/tile.vue';
import MovieTile from '#/components/movies/tile.vue';
import SerieTile from '#/components/series/tile.vue';
import Chapters from '#/components/scenes/chapters.vue';
import Heart from '#/components/stashes/heart.vue';
import Campaign from '#/components/campaigns/campaign.vue';
@ -380,6 +387,8 @@ const {
const { scene } = pageProps;
console.log(scene);
const showSummaryDialog = ref(false);
const qualities = {

View File

@ -58,6 +58,8 @@ function curateScene(rawScene, assets) {
return null;
}
console.log(assets.chapters);
const curatedScene = {
id: rawScene.id,
title: rawScene.title,
@ -113,6 +115,18 @@ function curateScene(rawScene, assets) {
name: tag.name,
priority: tag.priority,
})),
chapters: assets.chapters.map((chapter) => ({
id: chapter.id,
title: chapter.title,
time: chapter.time,
duration: chapter.duration,
poster: curateMedia(chapter.chapter_poster),
tags: chapter.chapter_tags.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
})),
})),
qualities: rawScene.qualities?.sort((qualityA, qualityB) => qualityB - qualityA) || [],
photoCount: rawScene.photo_count,
movies: assets.movies.map((movie) => ({
@ -151,6 +165,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
actors,
directors,
tags,
chapters,
movies,
series,
posters,
@ -202,6 +217,19 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereNotNull('tags.id')
.whereIn('release_id', sceneIds)
.orderBy('priority', 'desc'),
chapters: knex('chapters')
.select(
'chapters.*',
knex.raw('coalesce(json_agg(tags) filter (where tags.id is not null), \'[]\') as chapter_tags'),
knex.raw('row_to_json(posters) as chapter_poster'),
)
.leftJoin('chapters_tags', 'chapters_tags.chapter_id', 'chapters.id')
.leftJoin('tags', 'tags.id', 'chapters_tags.tag_id')
.leftJoin('chapters_posters', 'chapters_posters.chapter_id', 'chapters.id')
.leftJoin('media as posters', 'posters.id', 'chapters_posters.media_id')
.whereIn('chapters.release_id', sceneIds)
.groupBy('chapters.id', 'posters.id')
.orderBy('time', 'asc'),
movies: context.includePartOf ? knex('movies_scenes')
.select('movies_scenes.scene_id', 'movies.*', knex.raw('json_agg(media) as movie_covers'))
.leftJoin('movies', 'movies.id', 'movies_scenes.movie_id')
@ -289,6 +317,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
const sceneActors = actors.filter((actor) => actor.release_id === sceneId);
const sceneDirectors = directors.filter((director) => director.release_id === sceneId);
const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
const sceneChapters = chapters.filter((chapter) => chapter.release_id === sceneId);
const sceneMovies = movies.filter((movie) => movie.scene_id === sceneId);
const sceneSeries = series.filter((serie) => serie.scene_id === sceneId);
const scenePoster = posters.find((poster) => poster.release_id === sceneId);
@ -305,6 +334,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
actors: sceneActors,
directors: sceneDirectors,
tags: sceneTags,
chapters: sceneChapters,
movies: sceneMovies,
series: sceneSeries,
poster: scenePoster,