Skip to content

Commit

Permalink
feat: Add Media Segments
Browse files Browse the repository at this point in the history
  • Loading branch information
endrl committed Nov 3, 2023
1 parent 4370a78 commit 465d513
Show file tree
Hide file tree
Showing 6 changed files with 392 additions and 0 deletions.
91 changes: 91 additions & 0 deletions frontend/src/components/Buttons/SegmentSkipButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<template>
<VSlideYReverseTransition>
<VBtn
v-if="show"
ref="btn"
variant="outlined"
:size="props.large ? 'large' : 'small'"
:style="btnStyle"
class="skip-segment"
@click="skipSegment()">
Skip {{ segment.Type }}
<template
v-if="props.large"
#append>
<VIcon size="32">
<IMdiSkipNext />
</VIcon>
</template>
</VBtn>
</VSlideYReverseTransition>
</template>

<script setup lang="ts">
import IMdiSkipNext from 'virtual:icons/mdi/skip-next';
import { computed, shallowRef, watch, nextTick } from 'vue';
import { playbackManagerStore } from '@/store';
import { MediaSegment } from 'types/global/mediaSegment';
import { ticksToMs } from '@/utils/time';
// Props
const props = defineProps<{
segment: MediaSegment;
large?: boolean;
}>();
const playbackManager = playbackManagerStore();
const btn = shallowRef<HTMLElement>();
/*
* Vars
* Show button seconds before (Segment.Start - 2)
*/
const SHOW_START_OFFSET = 2;
// Hide button seconds after (Segment.Start + 8)
const SHOW_END_OFFSET = 8;
const runtime = computed(
() => ticksToMs(playbackManager.currentItem?.RunTimeTicks) / 1000
);
const show = computed((): boolean => {
if (!props.segment) {
return false;
}
return (
(playbackManager?.currentTime || 0) >=
Math.max(props.segment.Start - SHOW_START_OFFSET, 0) &&
(playbackManager?.currentTime || 0) <=
Math.min(props.segment.Start + SHOW_END_OFFSET, runtime.value)
);
});
watch(show, (now) => {
if (now) {
// Force focus
nextTick(() => btn?.value?.$el?.focus());
}
});
const btnStyle = computed(() => {
return {
bottom: props.large ? '19%' : '8%' };
});
/**
*
*/
function skipSegment (): void {
playbackManager.currentTime = props.segment.End - 2; // Additional -2s to prevent skipped content
}
</script>

<style lang="scss" scoped>
.skip-segment {
overflow: hidden;
background: rgb(0 0 0 / 28%);
z-index: 9999;
left: 3%;
position: absolute;
}
</style>
130 changes: 130 additions & 0 deletions frontend/src/components/Layout/MediaSegments.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<template>
<SegmentSkipButton
v-for="segment in segmentsWithPrompt"
:key="segment.ItemId + segment.Type + segment.TypeIndex"
:segment="segment"
:large="playerElement.isFullscreenVideoPlayer" />
<SegmentOverlay
v-for="segment in segmentsWithoutPrompt"
:key="segment.ItemId + segment.Type + segment.TypeIndex"
:segment="segment"
:large="playerElement.isFullscreenVideoPlayer" />
</template>

<script setup lang="ts">
import { computed, ref, watch , nextTick } from 'vue';
import { AxiosRequestConfig } from 'axios';
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
import { playbackManagerStore, playerElementStore } from '@/store';
import { MediaSegment } from 'types/global/mediaSegment';
import { useRemote } from '@/composables';
import RemotePluginAxiosInstance from '@/plugins/remote/axios';
const remote = useRemote();
const playbackManager = playbackManagerStore();
const playerElement = playerElementStore();
const currentSegments = ref<MediaSegment[]>([]);
const axios = RemotePluginAxiosInstance;
enum MediaSegmentType {
INTRO = 'Intro',
OUTRO = 'Outro',
PREVIEW = 'Preview',
RECAP = 'Recap',
COMMERCIAL = 'Commercial'
}
enum MediaSegmentAction {
AUTO = 'Auto',
NONE = 'None',
SKIP = 'Skip',
PROMPT = 'Prompt',
MUTE = 'Mute'
}
/**
* Get default action for segments by segment type if action is AUTO
*/
function getDefaultActionforType (seg: MediaSegment) : MediaSegmentAction {
if (seg.Action == MediaSegmentAction.AUTO){
switch (seg.Type) {
case MediaSegmentType.INTRO: {
return MediaSegmentAction.PROMPT;
}
case MediaSegmentType.OUTRO: {
return MediaSegmentAction.PROMPT;
}
case MediaSegmentType.PREVIEW: {
return MediaSegmentAction.PROMPT;
}
case MediaSegmentType.RECAP: {
return MediaSegmentAction.PROMPT;
}
case MediaSegmentType.COMMERCIAL: {
return MediaSegmentAction.SKIP;
}
default: {
return MediaSegmentAction.AUTO;
}
}
} else {
return seg.Action;
}
}
const segmentsWithPrompt = computed(() =>
currentSegments.value.filter((seg) => getDefaultActionforType(seg) == MediaSegmentAction.PROMPT)
);
const segmentsWithoutPrompt = computed(() =>
currentSegments.value.filter((seg) => getDefaultActionforType(seg) != MediaSegmentAction.PROMPT)
);
watch(
() => playbackManager.currentItem,
(item: BaseItemDto | undefined) => {
void fetchMediaSegments(item);
}
);
let fetchMediaSegments = async function (
item: BaseItemDto | undefined
): Promise<void> {
// Reset
currentSegments.value = [];
if (item === undefined) {
return;
}
if (
item?.Type === BaseItemKind.Episode ||
item?.Type === BaseItemKind.Movie
) {
try {
let token = remote.auth.currentUserToken;
let userId = remote.auth.currentUserId;
let config: AxiosRequestConfig = {
headers: { Authorization: `MediaBrowser Token="${token}"` }
};
const segmentsResponse = await axios.instance.get(
`/MediaSegment?itemId=${item?.Id}&userId=${userId}`,
config
);
// Inject Action
for (const [ind, el] of segmentsResponse.data.Items.entries()) {
segmentsResponse.data.Items[ind].Action = getDefaultActionforType(el);
}
currentSegments.value = segmentsResponse.data.Items;
} catch (error) {
console.error('Fetching segs', error);
}
}
};
</script>
143 changes: 143 additions & 0 deletions frontend/src/components/Layout/SegmentOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<VSlideYReverseTransition>
<VBtn
v-if="show"
variant="outlined"
:size="props.large ? 'large' : 'small'"
:style="btnStyle"
class="overlay-segment">
{{ btnText }}
<template
v-if="props.large"
#prepend>
<VIcon size="32">
<IMdiVolumeMute
v-if="props.segment.Action == MediaSegmentAction.MUTE" />
<IMdiInformationOutline v-else />
</VIcon>
</template>
</VBtn>
</VSlideYReverseTransition>
</template>

<script setup lang="ts">
import IMdiInformationOutline from 'virtual:icons/mdi/information-outline';
import IMdiVolumeMute from 'virtual:icons/mdi/volume-mute';
import { computed, watch } from 'vue';
import { playbackManagerStore } from '@/store';
import { MediaSegment } from 'types/global/mediaSegment';
import { ticksToMs } from '@/utils/time';
// Props
const props = defineProps<{
segment: MediaSegment;
large?: boolean;
}>();
const playbackManager = playbackManagerStore();
enum MediaSegmentType {
INTRO = 'Intro',
OUTRO = 'Outro',
PREVIEW = 'Preview',
RECAP = 'Recap',
COMMERCIAL = 'Commercial'
}
enum MediaSegmentAction {
AUTO = 'Auto',
NONE = 'None',
SKIP = 'Skip',
PROMPT = 'Prompt',
MUTE = 'Mute'
}
// Show message seconds before (Segment.Start - 0)
const SHOW_START_OFFSET = 0;
// Hide message seconds after (Segment.Start + 5)
const SHOW_END_OFFSET = 7;
// We execute and show just once
let executed = false;
const runtime = computed(
() => ticksToMs(playbackManager.currentItem?.RunTimeTicks) / 1000
);
const show = computed((): boolean => {
if (!props.segment) {
return false;
}
let state = false;
if (props.segment.Action == MediaSegmentAction.MUTE && !executed) {
state =
(playbackManager?.currentTime || 0) >=
Math.max(props.segment.Start - SHOW_START_OFFSET, 0) &&
(playbackManager?.currentTime || 0) <=
Math.min(props.segment.Start + SHOW_END_OFFSET, runtime.value);
}
if (props.segment.Action == MediaSegmentAction.SKIP && !executed) {
state =
(playbackManager?.currentTime || 0) >=
Math.max(props.segment.Start - SHOW_START_OFFSET, 0) &&
(playbackManager?.currentTime || 0) <=
Math.min(props.segment.End + SHOW_END_OFFSET, runtime.value);
}
return state;
});
watch(show, (val) => {
if (val) {
if (props.segment.Action == MediaSegmentAction.SKIP && !executed) {
playbackManager.currentTime = props.segment.End - 2; // Prevent content skipped
}
if (
props.segment.Action == MediaSegmentAction.MUTE &&
!executed &&
!playbackManager.isMuted
) {
playbackManager.toggleMute();
}
} else {
if (
props.segment.Action == MediaSegmentAction.MUTE &&
playbackManager.isMuted
) {
playbackManager.toggleMute();
}
// Mark as executed, when segment is hidden again
executed = true;
}
});
const btnText = computed(() => {
if (props.segment.Action == MediaSegmentAction.MUTE) {
return `Mute ${props.segment.Type}`;
}
if (props.segment.Action == MediaSegmentAction.SKIP) {
return `Skipped ${props.segment.Type}`;
}
});
const btnStyle = computed(() => {
return {
bottom: props.large ? '19%' : '8%'
};
});
</script>

<style>
.overlay-segment {
overflow: hidden;
background: rgb(0 0 0 / 28%);
z-index: 9999;
left: 3%;
position: absolute;
}
</style>
1 change: 1 addition & 0 deletions frontend/src/components/Playback/PlayerElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
:srclang="sub.srcLang"
:src="sub.src" />
</Component>
<MediaSegments />
</Teleport>
</template>
</template>
Expand Down
3 changes: 3 additions & 0 deletions frontend/types/global/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ declare module 'vue' {
MediaDetailContent: typeof import('./../../src/components/Item/MediaDetail/MediaDetailContent.vue')['default']
MediaDetailDialog: typeof import('./../../src/components/Item/MediaDetail/MediaDetailDialog.vue')['default']
MediaInfo: typeof import('./../../src/components/Item/MediaInfo.vue')['default']
MediaSegments: typeof import('./../../src/components/Layout/MediaSegments.vue')['default']
MediaSourceSelector: typeof import('./../../src/components/Item/MediaSourceSelector.vue')['default']
MediaStreamSelector: typeof import('./../../src/components/Item/MediaStreamSelector.vue')['default']
MetadataEditor: typeof import('./../../src/components/Item/Metadata/MetadataEditor.vue')['default']
Expand All @@ -128,6 +129,8 @@ declare module 'vue' {
ScrollToTopButton: typeof import('./../../src/components/Buttons/ScrollToTopButton.vue')['default']
SearchField: typeof import('./../../src/components/Layout/AppBar/SearchField.vue')['default']
SeasonTabs: typeof import('./../../src/components/Item/SeasonTabs.vue')['default']
SegmentOverlay: typeof import('./../../src/components/Layout/SegmentOverlay.vue')['default']
SegmentSkipButton: typeof import('./../../src/components/Buttons/SegmentSkipButton.vue')['default']
ServerCard: typeof import('./../../src/components/Item/Card/ServerCard.vue')['default']
SettingsPage: typeof import('./../../src/components/Layout/SettingsPage.vue')['default']
ShuffleButton: typeof import('./../../src/components/Buttons/Playback/ShuffleButton.vue')['default']
Expand Down
Loading

0 comments on commit 465d513

Please sign in to comment.