Improvements to tv watch page

This commit is contained in:
ward
2025-06-23 14:13:26 +12:00
parent ef74dd14d7
commit 947b3d94e6
10 changed files with 309 additions and 141 deletions
@@ -10,7 +10,7 @@
async function checkWidth() {
if ($isAndroidTvStore) {
mediumCol = '4';
mediumCol = '3';
} else if (innerWidth <= 1750) {
largeCol = '4';
} else {
+13 -22
View File
@@ -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.",
+7
View File
@@ -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>