Improvements to tv watch page
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
async function checkWidth() {
|
||||
if ($isAndroidTvStore) {
|
||||
mediumCol = '4';
|
||||
mediumCol = '3';
|
||||
} else if (innerWidth <= 1750) {
|
||||
largeCol = '4';
|
||||
} else {
|
||||
|
||||
@@ -525,18 +525,20 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement) return;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
if (!$isAndroidTvStore) {
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement) return;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement) return;
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement) return;
|
||||
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
Mousetrap.bind('c', () => {
|
||||
const isVisible = player.isTextTrackVisible();
|
||||
@@ -580,17 +582,6 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
if ($isAndroidTvStore) {
|
||||
Mousetrap.bind('enter', () => {
|
||||
if (playerElement?.paused) {
|
||||
playerElement?.play();
|
||||
} else {
|
||||
playerElement?.pause();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
playerElement.addEventListener('ended', async () => {
|
||||
if (!data.playlistId) {
|
||||
if ($playerAutoplayNextByDefaultStore) {
|
||||
@@ -761,7 +752,7 @@
|
||||
id="player"
|
||||
poster={getBestThumbnail(data.video.videoThumbnails, 9999, 9999)}
|
||||
></video>
|
||||
{#if isEmbed}
|
||||
{#if isEmbed && !isAndroidTvStore}
|
||||
<div class="chip blur embed" style="position: absolute;top: 10px;left: 10px;font-size: 18px;">
|
||||
{data.video.title}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
let placeholderHeight: number = $state(0);
|
||||
|
||||
let watchUrl = new URL(`${location.origin}/watch/${video.videoId}`);
|
||||
let watchUrl = new URL(
|
||||
`${location.origin}/${$isAndroidTvStore ? 'tv' : 'watch'}/${video.videoId}`
|
||||
);
|
||||
|
||||
if (playlistId !== '') {
|
||||
watchUrl.searchParams.set('playlist', playlistId);
|
||||
@@ -139,7 +141,7 @@
|
||||
|
||||
function calcThumbnailPlaceholderHeight() {
|
||||
if ($isAndroidTvStore) {
|
||||
placeholderHeight = 400;
|
||||
placeholderHeight = 100;
|
||||
return;
|
||||
}
|
||||
if (!sideways) {
|
||||
@@ -163,9 +165,7 @@
|
||||
tabindex="0"
|
||||
role="button"
|
||||
onclick={async () => {
|
||||
if ($isAndroidTvStore) {
|
||||
goto(`${location.origin}/tv/${video.videoId}`);
|
||||
}
|
||||
goto(watchUrl);
|
||||
}}
|
||||
>
|
||||
<div id="thumbnail-container">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { deleteUnsubscribe, postSubscribe } from '$lib/api';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { truncate } from '$lib/misc';
|
||||
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { video, subscribed = $bindable(false) }: { video: VideoPlay; subscribed?: boolean } =
|
||||
$props();
|
||||
|
||||
async function toggleSubscribed() {
|
||||
if (subscribed) {
|
||||
await deleteUnsubscribe(video.authorId);
|
||||
} else {
|
||||
await postSubscribe(video.authorId);
|
||||
}
|
||||
|
||||
subscribed = !subscribed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href={`/channel/${video.authorId}`}>
|
||||
<nav style="gap: 0.5em;">
|
||||
{#if !$interfaceLowBandwidthMode}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="circle large"
|
||||
src={proxyGoogleImage(getBestThumbnail(video.authorThumbnails))}
|
||||
alt="Channel profile"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<p style="margin: 0;" class="bold">
|
||||
{$isAndroidTvStore ? video.author : truncate(video.author, 16)}
|
||||
</p>
|
||||
<p style="margin: 0;">{video.subCountText}</p>
|
||||
</div>
|
||||
</nav>
|
||||
</a>
|
||||
{#if $authStore}
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
class:inverse-surface={!subscribed}
|
||||
class:border={subscribed}
|
||||
>
|
||||
{#if !subscribed}
|
||||
{$_('subscribe')}
|
||||
{:else}
|
||||
{$_('unsubscribe')}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="inverse-surface" disabled>
|
||||
{$_('subscribe')}
|
||||
<div class="tooltip">
|
||||
{$_('loginRequired')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { numberWithCommas } from '$lib/numbers';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { onMount } from 'svelte';
|
||||
import { expandSummery } from '$lib/misc';
|
||||
import { interfaceAutoExpandDesc } from '$lib/store';
|
||||
|
||||
let { video, description }: { video: VideoPlay; description: string } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if ($interfaceAutoExpandDesc) {
|
||||
expandSummery('description');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<details>
|
||||
<summary id="description" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
{numberWithCommas(video.viewCount)}
|
||||
{$_('views')} • {video.publishedText}
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div class="medium scroll">
|
||||
<div style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
{@html description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="scroll">
|
||||
{#if video.keywords}
|
||||
{#each video.keywords as keyword}
|
||||
<a href={`/search/${encodeURIComponent(keyword)}`} class="chip">{keyword}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</nav>
|
||||
</details>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { ReturnYTDislikes, VideoPlay } from '$lib/api/model';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
|
||||
let {
|
||||
video,
|
||||
returnYTDislikes
|
||||
}: { video: VideoPlay; returnYTDislikes?: Promise<ReturnYTDislikes> | null } = $props();
|
||||
</script>
|
||||
|
||||
{#await returnYTDislikes then returnYTDislikes}
|
||||
{#if returnYTDislikes}
|
||||
<nav class="no-space">
|
||||
<button style="cursor: default;" class="border left-round">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(returnYTDislikes.likes)}</span>
|
||||
</button>
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border right-round">
|
||||
<i class="small">thumb_down_alt</i>
|
||||
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
|
||||
</button>
|
||||
</nav>
|
||||
{:else}
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(video.likeCount)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -5,6 +5,8 @@
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
"login": "Login",
|
||||
"recommendedVideos": "Recommended Videos",
|
||||
"playlistVideos": "Playlist Videos",
|
||||
"invidiousLogin": "Please log in with your Invidious account",
|
||||
"invalidInstance": "Please verify the URL. If it's correct, the instance may be down or may not support third-party clients.",
|
||||
"invidiousBlockWarning": "Invidious is currently being blocked by Google. If videos aren't loading for this instance, please use this instance on Materialious on {android} or {desktop} to get around this with local video fallback.",
|
||||
|
||||
@@ -106,3 +106,10 @@ export function excludeDuplicateFeeds(currentItems: feedItems, newItems: feedIte
|
||||
|
||||
return [...nonDuplicatedNewItems, ...currentItems];
|
||||
}
|
||||
|
||||
export function expandSummery(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
addPlaylistVideo,
|
||||
deleteUnsubscribe,
|
||||
getComments,
|
||||
getPersonalPlaylists,
|
||||
postSubscribe,
|
||||
removePlaylistVideo
|
||||
} from '$lib/api/index';
|
||||
import type { Comments, PlaylistPage } from '$lib/api/model';
|
||||
@@ -13,17 +11,14 @@
|
||||
import ShareVideo from '$lib/components/ShareVideo.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Transcript from '$lib/components/Transcript.svelte';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { truncate } from '$lib/misc';
|
||||
import { cleanNumber, humanizeSeconds, numberWithCommas } from '$lib/numbers';
|
||||
import type { PlayerEvents } from '$lib/player.js';
|
||||
import {
|
||||
authStore,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
interfaceAutoExpandDesc,
|
||||
interfaceLowBandwidthMode,
|
||||
playerTheatreModeByDefaultStore,
|
||||
playlistCacheStore,
|
||||
playlistSettingsStore,
|
||||
@@ -37,6 +32,10 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { loadEntirePlaylist } from '$lib/playlist.js';
|
||||
import Author from '$lib/components/Watch/Author.svelte';
|
||||
import Description from '$lib/components/Watch/Description.svelte';
|
||||
import { expandSummery } from '$lib/misc.js';
|
||||
import LikesDislikes from '$lib/components/Watch/LikesDislikes.svelte';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -48,9 +47,7 @@
|
||||
});
|
||||
|
||||
let subscribed: boolean = $state(false);
|
||||
data.streamed.subscribed.then((streamedIsSubbed) => {
|
||||
subscribed = streamedIsSubbed;
|
||||
});
|
||||
data.streamed.subscribed.then((isSubbed) => (subscribed = isSubbed));
|
||||
|
||||
let personalPlaylists: PlaylistPage[] | null = $state(null);
|
||||
data.streamed.personalPlaylists?.then((streamPlaylists) => (personalPlaylists = streamPlaylists));
|
||||
@@ -68,13 +65,6 @@
|
||||
|
||||
let playerCurrentTime: number = $state(0);
|
||||
|
||||
function expandSummery(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($interfaceAutoExpandComments && comments) {
|
||||
expandSummery('comment-section');
|
||||
@@ -225,10 +215,6 @@
|
||||
await goToCurrentPlaylistItem();
|
||||
}
|
||||
|
||||
if ($interfaceAutoExpandDesc) {
|
||||
expandSummery('description');
|
||||
}
|
||||
|
||||
if ($interfaceAutoExpandChapters) {
|
||||
expandSummery('chapter-section');
|
||||
}
|
||||
@@ -307,16 +293,6 @@
|
||||
comments.comments = [...comments.comments, ...loadedComments.comments];
|
||||
}
|
||||
|
||||
async function toggleSubscribed() {
|
||||
if (subscribed) {
|
||||
await deleteUnsubscribe(data.video.authorId);
|
||||
} else {
|
||||
await postSubscribe(data.video.authorId);
|
||||
}
|
||||
|
||||
subscribed = !subscribed;
|
||||
}
|
||||
|
||||
function toggleTheatreMode() {
|
||||
theatreMode = !theatreMode;
|
||||
}
|
||||
@@ -354,65 +330,10 @@
|
||||
|
||||
<div class="grid no-padding">
|
||||
<div class="s12 m12 l5">
|
||||
<nav>
|
||||
<a href={`/channel/${data.video.authorId}`}>
|
||||
<nav style="gap: 0.5em;">
|
||||
{#if !$interfaceLowBandwidthMode}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="circle large"
|
||||
src={proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))}
|
||||
alt="Channel profile"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<p style="margin: 0;" class="bold">{truncate(data.video.author, 16)}</p>
|
||||
<p style="margin: 0;">{data.video.subCountText}</p>
|
||||
</div>
|
||||
</nav>
|
||||
</a>
|
||||
{#if $authStore}
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
class:inverse-surface={!subscribed}
|
||||
class:border={subscribed}
|
||||
>
|
||||
{#if !subscribed}
|
||||
{$_('subscribe')}
|
||||
{:else}
|
||||
{$_('unsubscribe')}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="inverse-surface" disabled>
|
||||
{$_('subscribe')}
|
||||
<div class="tooltip">
|
||||
{$_('loginRequired')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
<Author video={data.video} bind:subscribed />
|
||||
</div>
|
||||
<div class="s12 m12 l7 video-actions">
|
||||
{#await data.streamed.returnYTDislikes then returnYTDislikes}
|
||||
{#if returnYTDislikes}
|
||||
<nav class="no-space">
|
||||
<button style="cursor: default;" class="border left-round">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(returnYTDislikes.likes)}</span>
|
||||
</button>
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border right-round">
|
||||
<i class="small">thumb_down_alt</i>
|
||||
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
|
||||
</button>
|
||||
</nav>
|
||||
{:else}
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(data.video.likeCount)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/await}
|
||||
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
|
||||
|
||||
<div>
|
||||
<button onclick={toggleTheatreMode} class="m l" class:border={!theatreMode}>
|
||||
@@ -497,31 +418,7 @@
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<details>
|
||||
<summary id="description" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
{numberWithCommas(data.video.viewCount)}
|
||||
{$_('views')} • {data.video.publishedText}
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div class="medium scroll">
|
||||
<div style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
{@html data.content.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="scroll">
|
||||
{#if data.video.keywords}
|
||||
{#each data.video.keywords as keyword}
|
||||
<a href={`/search/${encodeURIComponent(keyword)}`} class="chip">{keyword}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</nav>
|
||||
</details>
|
||||
<Description video={data.video} description={data.content.description} />
|
||||
</article>
|
||||
|
||||
{#if data.content.timestamps.length > 0}
|
||||
|
||||
@@ -1,9 +1,147 @@
|
||||
<script lang="ts">
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Author from '$lib/components/Watch/Author.svelte';
|
||||
import Description from '$lib/components/Watch/Description.svelte';
|
||||
import LikesDislikes from '$lib/components/Watch/LikesDislikes.svelte';
|
||||
import { letterCase } from '$lib/letterCasing.js';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { playlistCacheStore } from '$lib/store.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let playerElement: HTMLMediaElement;
|
||||
let playerElement: HTMLMediaElement | undefined = $state();
|
||||
let showInfo = $state(false);
|
||||
|
||||
let subscribed: boolean = $state(false);
|
||||
data.streamed.subscribed.then((isSubbed) => (subscribed = isSubbed));
|
||||
|
||||
onMount(() => {
|
||||
Mousetrap.bind('down', () => {
|
||||
if (showInfo) return true;
|
||||
|
||||
showInfo = true;
|
||||
tick().then(() => {
|
||||
document.getElementById('shown-info')?.focus();
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('up', () => {
|
||||
const infoElement = document.getElementById('shown-info');
|
||||
|
||||
if (showInfo && infoElement) {
|
||||
if (infoElement.scrollTop === 0) {
|
||||
showInfo = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!showInfo) {
|
||||
showInfo = true;
|
||||
tick().then(() => {
|
||||
document.getElementById('shown-info')?.focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement || showInfo) return true;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement || showInfo) return true;
|
||||
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('enter', () => {
|
||||
if (showInfo) return true;
|
||||
|
||||
if (playerElement?.paused) {
|
||||
playerElement?.play();
|
||||
} else {
|
||||
playerElement?.pause();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
Mousetrap.unbind(['up', 'down', 'left', 'right']);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Player bind:playerElement isEmbed={true} {data} />
|
||||
|
||||
{#if showInfo}
|
||||
<article id="shown-info">
|
||||
<h5>{letterCase(data.video.title)}</h5>
|
||||
<Author bind:subscribed video={data.video} />
|
||||
<div class="space"></div>
|
||||
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
|
||||
<article class="border">
|
||||
<Description video={data.video} description={data.content.description} />
|
||||
</article>
|
||||
|
||||
{#if data.playlistId && data.playlistId in $playlistCacheStore}
|
||||
<h5 style="margin-bottom: 0;">{$_('playlistVideos')}</h5>
|
||||
|
||||
<div class="grid">
|
||||
{#each $playlistCacheStore[data.playlistId].videos as playlistVideo}
|
||||
<ContentColumn>
|
||||
<article
|
||||
class="no-padding primary-border"
|
||||
style="height: 100%;"
|
||||
id={playlistVideo.videoId}
|
||||
class:border={playlistVideo.videoId === data.video.videoId}
|
||||
>
|
||||
{#key playlistVideo.videoId}
|
||||
<Thumbnail
|
||||
video={playlistVideo}
|
||||
sideways={true}
|
||||
playlistId={data.playlistId || undefined}
|
||||
/>
|
||||
{/key}
|
||||
</article>
|
||||
</ContentColumn>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<h5 style="margin-bottom: 0;">{$_('recommendedVideos')}</h5>
|
||||
<div class="grid">
|
||||
{#each data.video.recommendedVideos as recommendedVideo}
|
||||
<ContentColumn>
|
||||
<article style="height: 100%;" class="no-padding">
|
||||
{#key recommendedVideo.videoId}
|
||||
<Thumbnail video={recommendedVideo} sideways={false} />
|
||||
{/key}
|
||||
</article>
|
||||
</ContentColumn>
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#shown-info {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
z-index: 101;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user