Improved share component

This commit is contained in:
WardPearce
2026-02-18 06:07:02 +13:00
parent e0981bcdd7
commit ccd800dbb3
11 changed files with 319 additions and 265 deletions
@@ -0,0 +1,85 @@
<script lang="ts">
import { shareURL } from '$lib/misc';
import { _ } from '$lib/i18n';
import { invidiousInstanceStore } from '$lib/store';
import { Capacitor } from '@capacitor/core';
import { titleCase } from '$lib/letterCasing';
type ShareLink = {
type: 'invidious' | 'youtube' | 'materialious' | 'invidious redirect';
path: string;
param?: {
key: string;
value: () => string | number;
};
};
let {
shares,
includePromptText = undefined,
iconOnly = true
}: {
shares: ShareLink[];
includePromptText?: string;
iconOnly: boolean;
} = $props();
const shareBase = {
invidious: $invidiousInstanceStore,
youtube: 'https://www.youtube.com',
materialious: !Capacitor.isNativePlatform() ? location.origin : undefined,
'invidious redirect': 'https://redirect.invidious.io'
};
let includePrompt = $state(false);
async function onShare(share: ShareLink) {
const url = new URL(`${shareBase[share.type]}${share.path}`);
if (share.param && includePrompt)
url.searchParams.append(share.param.key, share.param.value().toString());
await shareURL(url.toString());
}
</script>
<button class="surface-container-highest" onclick={(event: Event) => event.stopPropagation()}>
<i>share</i>
{#if !iconOnly}
{$_('player.share.title')}
{/if}
<div class="tooltip">
{$_('player.share.title')}
</div>
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
{#if includePromptText}
<li class="row">
<label class="switch">
<input type="checkbox" bind:checked={includePrompt} />
<span></span>
</label>
<div class="min">{includePromptText}</div>
</li>
<div class="divider"></div>
{/if}
{#each shares as share (share)}
{#if shareBase[share.type]}
<li
data-ui="#share-menu"
class="row"
role="presentation"
onclick={() => {
onShare(share);
}}
>
<div class="min">
{$_('player.share.copyXLink', {
linkType: titleCase(share.type)
})}
</div>
</li>
{/if}
{/each}
</menu>
</button>
@@ -1,79 +0,0 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { invidiousInstanceStore } from '$lib/store';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
import { isUnrestrictedPlatform, shareURL } from '$lib/misc';
import { addToast } from './Toast.svelte';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
currentTime?: number;
}
let { video, currentTime = $bindable() }: Props = $props();
let includeTimestamp: boolean = $state(false);
async function shareVideo(url: string, param: string = 't') {
if (includeTimestamp) url += `?${param}=${Math.floor(currentTime ?? 0)}`;
await shareURL(url);
addToast({
data: {
text: $_('player.share.copiedSuccess')
}
});
}
</script>
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
{#if currentTime !== undefined}
<li class="row">
<label class="switch">
<input type="checkbox" bind:checked={includeTimestamp} />
<span></span>
</label>
<div class="min">{$_('player.share.includeTimestamp')}</div>
</li>
<div class="divider"></div>
{/if}
<li
data-ui="#share-menu"
class="row"
role="presentation"
onclick={async () => {
if (isUnrestrictedPlatform()) {
shareVideo(`${get(invidiousInstanceStore)}/watch/${video.videoId}`);
} else {
shareVideo(
`${location.origin}${resolve('/watch/[videoId]', { videoId: video.videoId })}`,
'time'
);
}
}}
>
<div class="min">{$_('player.share.materialiousLink')}</div>
</li>
<li
data-ui="#share-menu"
class="row"
role="presentation"
onclick={async () => {
shareVideo(`https://redirect.invidious.io/watch?v=${video.videoId}`);
}}
>
<div class="min">{$_('player.share.invidiousRedirect')}</div>
</li>
<li
data-ui="#share-menu"
class="row"
role="presentation"
onclick={async () => {
shareVideo(`https://www.youtube.com/watch?v=${video.videoId}`);
}}
>
<div class="min">{$_('player.share.youtubeLink')}</div>
</li>
</menu>
@@ -16,7 +16,7 @@
{#if playerTextTracks && playerTextTracks.length > 0 && !video.liveNow}
<button class="surface-container-highest">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<menu class="no-wrap mobile player-settings" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
@@ -0,0 +1,122 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PlaylistPage, PlaylistPageVideo, VideoPlay } from '$lib/api/model';
import { _ } from '$lib/i18n';
import { cleanNumber } from '$lib/numbers';
import { goToNextVideo, goToPreviousVideo } from '$lib/player';
import { playlistSettingsStore } from '$lib/store';
import VideoThumbnail from '../thumbnail/VideoThumbnail.svelte';
let {
playlist,
video
}: { playlist: { videos: PlaylistPageVideo[]; info: PlaylistPage }; video: VideoPlay } = $props();
let loopPlaylist: boolean = $state(false);
let shufflePlaylist: boolean = $state(false);
playlistSettingsStore.subscribe((playlistSetting) => {
if (playlist.info.playlistId in playlistSetting) {
loopPlaylist = playlistSetting[playlist.info.playlistId].loop;
shufflePlaylist = playlistSetting[playlist.info.playlistId].shuffle;
}
});
</script>
<article
style="height: 85vh; position: relative;scrollbar-width: none;"
id="playlist"
class="scroll no-padding surface-container border"
>
<article class="no-elevate border" style="position: sticky; top: 0; z-index: 3;">
<h6>{playlist.info.title}</h6>
<p>
{cleanNumber(playlist.info.viewCount)}
{$_('views')}{playlist.info.videoCount}
{$_('videos')}
</p>
<p>
{#if playlist.info.authorId}
<a
href={resolve(`/channel/[authorId]`, {
authorId: playlist.info.authorId
})}>{playlist.info.author}</a
>
{:else}
{playlist.info.author}
{/if}
</p>
<nav>
<button
class="circle surface-container-highest"
onclick={() => goToPreviousVideo(playlist.info.playlistId)}
>
<i>skip_previous</i>
<div class="tooltip bottom">
{$_('playlist.previous')}
</div>
</button>
<button
onclick={() => {
loopPlaylist = !loopPlaylist;
playlistSettingsStore.set({
[playlist.info.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
});
}}
class="circle"
class:surface-container-highest={!loopPlaylist}
>
<i>loop</i>
<div class="tooltip bottom">
{$_('playlist.loopPlaylist')}
</div>
</button>
<button
onclick={() => {
shufflePlaylist = !shufflePlaylist;
playlistSettingsStore.set({
[playlist.info.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
});
}}
class="circle"
class:surface-container-highest={!shufflePlaylist}
>
<i>shuffle</i>
<div class="tooltip bottom">
{$_('playlist.shuffleVideos')}
</div>
</button>
<button
class="circle surface-container-highest"
onclick={async () => await goToNextVideo(video, playlist.info.playlistId)}
>
<i>skip_next</i>
<div class="tooltip bottom">
{$_('playlist.next')}
</div>
</button>
</nav>
<div class="space"></div>
<div class="divider"></div>
</article>
<div class="space"></div>
{#each playlist.videos as playlistVideo, index (index)}
<article
class="no-padding border"
style="margin: .7em;"
id={playlistVideo.videoId}
class:primary-border={playlistVideo.videoId === video.videoId}
>
{#key playlistVideo.videoId}
<VideoThumbnail
video={playlistVideo}
sideways={true}
playlistId={playlist.info.playlistId || undefined}
/>
{/key}
</article>
{/each}
</article>
@@ -86,7 +86,7 @@
<article class="scroll border no-padding" style="height: 75vh;" id="transcript">
<article class="no-elevate padding" style="position: sticky; top: 0; z-index: 3;">
<h6>{$_('transcript')}</h6>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select bind:value={url} onchange={loadTranscript} name="captions">
<option selected={true} value={null}>{$_('selectLang')}</option>
{#each video.captions as caption (caption)}
@@ -97,7 +97,8 @@
<i>arrow_drop_down</i>
</div>
{#if transcriptCues.length > 0}
<div class="max field round suffix prefix small no-margin surface-variant">
<div class="space"></div>
<div class="max field suffix prefix small no-margin surface-container-highest">
<i class="front">search</i><input
bind:value={search}
oninput={searchTranscript}
@@ -127,7 +128,8 @@
class="transcript-line"
role="presentation"
onclick={() => (playerElement.currentTime = cue.startTime)}
class:secondary-container={currentTime >= cue.startTime && currentTime <= cue.endTime}
class:surface-container-highest={currentTime >= cue.startTime &&
currentTime <= cue.endTime}
>
<p class="chip no-margin">{videoLength(cue.startTime)}</p>
<p class="transcript-text">{decodeHtmlCharCodes(cue.text.replace(/<[^>]+>/g, ''))}</p>
+1 -3
View File
@@ -126,9 +126,7 @@
"chapters": "Chapters",
"share": {
"title": "Share",
"materialiousLink": "Copy Materialious link",
"invidiousRedirect": "Copy Invidious redirect link",
"youtubeLink": "Copy Youtube link",
"copyXLink": "Copy {{linkType}} link",
"includeTimestamp": "Timestamped",
"copiedSuccess": "Copied to clipboard"
},
+8
View File
@@ -33,6 +33,8 @@ import { Clipboard } from '@capacitor/clipboard';
import { isOwnBackend } from './shared';
import { Browser } from '@capacitor/browser';
import { clearFeedYTjs } from './api/youtubejs/subscriptions';
import { addToast } from './components/Toast.svelte';
import { _ } from './i18n';
export function getPublicEnv(envName: string): string | undefined {
return env[`PUBLIC_${envName}`] ?? import.meta.env[`VITE_${envName}`];
@@ -114,6 +116,12 @@ export async function shareURL(url: string) {
} else {
await Clipboard.write({ string: url });
}
addToast({
data: {
text: get(_)('player.share.copiedSuccess')
}
});
}
export function ensureNoTrailingSlash(url: any): string {
+5
View File
@@ -6,6 +6,7 @@ import {
playerDefaultLanguage,
playerDefaultQualityStore,
playerPlaylistHistory,
playerState,
playlistSettingsStore,
syncPartyConnectionsStore
} from '$lib/store';
@@ -34,6 +35,8 @@ export const playbackRates = [
export const playerDoubleTapSeek = 10.0;
export function goToPreviousVideo(playlistId: string | null) {
playerState.set(undefined);
const previousVideos = get(playerPlaylistHistory);
if (previousVideos.length > 1) {
goto(
@@ -47,6 +50,8 @@ export function goToPreviousVideo(playlistId: string | null) {
}
export async function goToNextVideo(video: VideoPlay, playlistId: string | null) {
playerState.set(undefined);
const isAndroidTv = get(isAndroidTvStore);
if (!playlistId) {
@@ -14,6 +14,8 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { isYTBackend } from '$lib/misc';
import Share from '$lib/components/Share.svelte';
import { resolve } from '$app/paths';
let tab: ChannelContentTypes = $state('videos');
@@ -108,37 +110,29 @@
</p>
</div>
{#if !$isAndroidTvStore}
<button class="border">
<i>share</i>
<span>{$_('player.share.title')}</span>
<menu class="no-wrap mobile">
{#if !Capacitor.isNativePlatform()}
<li
class="row"
role="presentation"
onclick={async () => {
await Clipboard.write({ string: location.href });
(document.activeElement as HTMLElement)?.blur();
}}
>
{$_('player.share.materialiousLink')}
</li>
{/if}
<li
class="row"
role="presentation"
onclick={async () => {
await Clipboard.write({
string: `https://www.youtube.com/channel/${page.params.slug}`
});
(document.activeElement as HTMLElement)?.blur();
}}
>
{$_('player.share.youtubeLink')}
</li>
</menu>
</button>
<Share
iconOnly={false}
shares={[
{
type: 'materialious',
path: resolve('/channel/[channelId]', {
channelId: page.params.slug
})
},
{
type: 'youtube',
path: `/channel/${page.params.slug}`
},
{
type: 'invidious',
path: `/channel/${page.params.slug}`
},
{
type: 'invidious redirect',
path: `/channel/${page.params.slug}`
}
]}
/>
{/if}
</div>
@@ -3,10 +3,10 @@
import { unsafeRandomItem } from '$lib/misc';
import { cleanNumber } from '$lib/numbers';
import { isAndroidTvStore, playlistSettingsStore } from '$lib/store';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { _ } from '$lib/i18n';
import ItemsList from '$lib/components/layout/ItemsList.svelte';
import Share from '$lib/components/Share.svelte';
import { page } from '$app/state';
let { data } = $props();
</script>
@@ -44,7 +44,7 @@
playlistSettingsStore.set({
[data.playlist.info.playlistId]: { shuffle: true, loop: false }
})}
class="button circle extra no-margin border"
class="button circle extra no-margin surface-container-highest"
>
<i>shuffle</i>
<div class="tooltip bottom">
@@ -68,36 +68,29 @@
<div class="space"></div>
{#if !$isAndroidTvStore}
<button class="border no-margin">
<i>share</i>
<span>{$_('player.share.title')}</span>
<menu class="no-wrap mobile">
{#if !Capacitor.isNativePlatform()}
<li
class="row"
role="presentation"
onclick={async () => {
await Clipboard.write({ string: location.href });
(document.activeElement as HTMLElement)?.blur();
}}
>
{$_('player.share.materialiousLink')}
</li>
{/if}
<li
class="row"
role="presentation"
onclick={async () => {
await Clipboard.write({
string: `https://www.youtube.com/playlist?list=${data.playlist.info.playlistId}`
});
(document.activeElement as HTMLElement)?.blur();
}}
>
{$_('player.share.youtubeLink')}
</li>
</menu>
</button>
<nav class="right-align">
<Share
iconOnly={false}
shares={[
{
type: 'materialious',
path: resolve('/playlist/[playlistId]', { playlistId: page.params.slug })
},
{
type: 'invidious',
path: `/playlist?list=${page.params.slug}`
},
{
type: 'invidious redirect',
path: `/playlist?list=${page.params.slug}`
},
{
type: 'youtube',
path: `/playlist?list=${page.params.slug}`
}
]}
/>
</nav>
{/if}
</article>
@@ -7,7 +7,6 @@
removePlaylistVideo
} from '$lib/api/index';
import type { Comments, PlaylistPage } from '$lib/api/model';
import ShareVideo from '$lib/components/ShareVideo.svelte';
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
import Transcript from '$lib/components/watch/Transcript.svelte';
import { getBestThumbnail } from '$lib/images';
@@ -42,6 +41,8 @@
import { humanizeSeconds, relativeTimestamp } from '$lib/time';
import { getWatchDetails } from '$lib/watch';
import { page } from '$app/state';
import Share from '$lib/components/Share.svelte';
import Playlist from '$lib/components/watch/Playlist.svelte';
let { data = $bindable() } = $props();
@@ -55,9 +56,6 @@
let personalPlaylists: PlaylistPage[] | null = $state(null);
data.streamed.personalPlaylists?.then((streamPlaylists) => (personalPlaylists = streamPlaylists));
let loopPlaylist: boolean = $state(false);
let shufflePlaylist: boolean = $state(false);
playertheatreModeIsActive.set(get(playerTheatreModeByDefaultStore));
let pauseTimerSeconds: number = $state(-1);
@@ -84,14 +82,6 @@
}
});
playlistSettingsStore.subscribe((playlistSetting) => {
if (!data.playlistId) return;
if (data.playlistId in playlistSetting) {
loopPlaylist = playlistSetting[data.playlistId].loop;
shufflePlaylist = playlistSetting[data.playlistId].shuffle;
}
});
function playerSyncEvents(conn: DataConnection) {
if (playerElement) {
conn.send({
@@ -449,15 +439,44 @@
{$_('transcript')}
</div>
</button>
<button
class="surface-container-highest"
onclick={(event: Event) => event.stopPropagation()}
><i>share</i>
<div class="tooltip">
{$_('player.share.title')}
</div>
<ShareVideo bind:currentTime={playerCurrentTime} video={data.video} />
</button>
<Share
includePromptText={$_('player.share.includeTimestamp')}
shares={[
{
type: 'materialious',
path: resolve('/watch/[videoId]', { videoId: data.video.videoId }),
param: {
key: 'time',
value: () => Math.round(playerCurrentTime)
}
},
{
type: 'invidious',
path: `/watch?=${data.video.videoId}`,
param: {
key: 'v',
value: () => Math.round(playerCurrentTime)
}
},
{
type: 'invidious redirect',
path: `/watch?=${data.video.videoId}`,
param: {
key: 'v',
value: () => Math.round(playerCurrentTime)
}
},
{
type: 'youtube',
path: `/watch?=${data.video.videoId}`,
param: {
key: 'v',
value: () => Math.round(playerCurrentTime)
}
}
]}
iconOnly={true}
/>
{#if personalPlaylists && personalPlaylists.length > 0}
<button class="surface-container-highest">
<i>add</i>
@@ -581,100 +600,7 @@
<Transcript video={data.video} bind:playerElement />
{/if}
{#if data.playlistId && data.playlistId in $playlistCacheStore}
<article
style="height: 85vh; position: relative;scrollbar-width: none;"
id="playlist"
class="scroll no-padding surface-container border"
>
<article class="no-elevate border" style="position: sticky; top: 0; z-index: 3;">
<h6>{$playlistCacheStore[data.playlistId].info.title}</h6>
<p>
{cleanNumber($playlistCacheStore[data.playlistId].info.viewCount)}
{$_('views')}{$playlistCacheStore[data.playlistId].info.videoCount}
{$_('videos')}
</p>
<p>
{#if $playlistCacheStore[data.playlistId].info.authorId}
<a
href={resolve(`/channel/[authorId]`, {
authorId: $playlistCacheStore[data.playlistId].info.authorId
})}>{$playlistCacheStore[data.playlistId].info.author}</a
>
{:else}
{$playlistCacheStore[data.playlistId].info.author}
{/if}
</p>
<nav>
<button
onclick={() => {
loopPlaylist = !loopPlaylist;
playlistSettingsStore.set({
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
});
}}
class="circle"
class:fill={!loopPlaylist}
>
<i>loop</i>
<div class="tooltip bottom">
{$_('playlist.loopPlaylist')}
</div>
</button>
<button
onclick={() => {
shufflePlaylist = !shufflePlaylist;
playlistSettingsStore.set({
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
});
}}
class="circle"
class:fill={!shufflePlaylist}
>
<i>shuffle</i>
<div class="tooltip bottom">
{$_('playlist.shuffleVideos')}
</div>
</button>
<button class="circle fill" onclick={() => goToPreviousVideo(data.playlistId)}>
<i>skip_previous</i>
<div class="tooltip bottom">
{$_('playlist.previous')}
</div>
</button>
<button
class="circle fill"
onclick={async () => await goToNextVideo(data.video, data.playlistId)}
>
<i>skip_next</i>
<div class="tooltip bottom">
{$_('playlist.next')}
</div>
</button>
</nav>
<div class="space"></div>
<div class="divider"></div>
</article>
<div class="space"></div>
{#each $playlistCacheStore[data.playlistId].videos as playlistVideo (playlistVideo.videoId)}
<article
class="no-padding border"
style="margin: .7em;"
id={playlistVideo.videoId}
class:primary-border={playlistVideo.videoId === data.video.videoId}
>
{#key playlistVideo.videoId}
<Thumbnail
video={playlistVideo}
sideways={true}
playlistId={data.playlistId || undefined}
/>
{/key}
</article>
{/each}
</article>
<Playlist video={data.video} playlist={$playlistCacheStore[data.playlistId]} />
{:else if data.video.recommendedVideos}
{#each data.video.recommendedVideos as recommendedVideo (recommendedVideo.videoId)}
<article class="no-padding border">