Improved sync party to support Android & Desktop

This commit is contained in:
WardPearce
2026-01-17 00:28:37 +13:00
parent 07fb54ea39
commit b5646b948a
8 changed files with 192 additions and 62 deletions
+2
View File
@@ -190,6 +190,8 @@ export class ElectronCapacitorApp {
globalShortcut.register('Control+Shift+I', () => {
this.MainWindow.webContents.toggleDevTools();
});
this.MainWindow.webContents.toggleDevTools();
// Security
this.MainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
+42 -7
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.13.0",
"version": "1.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.13.0",
"version": "1.13.2",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -24,6 +24,7 @@
"@macfja/svelte-persistent-store": "^2.4.2",
"beercss": "^3.13.1",
"bgutils-js": "^3.2.0",
"buffer": "^6.0.3",
"capacitor-music-controls-plugin": "^6.1.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"dayjs": "^1.11.19",
@@ -42,7 +43,8 @@
"shaka-player": "^4.16.11",
"sponsorblock-api": "^0.2.4",
"svelte-infinite-loading": "^1.4.0",
"youtubei.js": "^16.0.1"
"youtubei.js": "^16.0.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
@@ -4951,6 +4953,30 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -5045,9 +5071,9 @@
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -5065,7 +5091,7 @@
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": {
@@ -14035,6 +14061,15 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+4 -2
View File
@@ -57,6 +57,7 @@
"@macfja/svelte-persistent-store": "^2.4.2",
"beercss": "^3.13.1",
"bgutils-js": "^3.2.0",
"buffer": "^6.0.3",
"capacitor-music-controls-plugin": "^6.1.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"dayjs": "^1.11.19",
@@ -75,6 +76,7 @@
"shaka-player": "^4.16.11",
"sponsorblock-api": "^0.2.4",
"svelte-infinite-loading": "^1.4.0",
"youtubei.js": "^16.0.1"
"youtubei.js": "^16.0.1",
"zod": "^4.3.5"
}
}
}
@@ -1,13 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { instanceStore, interfaceAndroidUseNativeShare } from '$lib/store';
import { Share } from '@capacitor/share';
import { instanceStore } from '$lib/store';
import ui from 'beercss';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
import { shareURL } from '$lib/misc';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
@@ -17,15 +16,12 @@
let { video, currentTime = $bindable() }: Props = $props();
let includeTimestamp: boolean = $state(false);
async function shareUrl(url: string, param: string = 't') {
async function shareVideo(url: string, param: string = 't') {
if (includeTimestamp) url += `?${param}=${Math.floor(currentTime ?? 0)}`;
if ((await Share.canShare()).value && $interfaceAndroidUseNativeShare) {
await Share.share({ url: url, dialogTitle: video.title });
} else {
await Clipboard.write({ string: url });
ui('#share-success');
}
await shareURL(url);
ui('#share-success');
}
</script>
@@ -46,9 +42,9 @@
role="presentation"
onclick={async () => {
if (Capacitor.isNativePlatform()) {
shareUrl(`${get(instanceStore)}/watch/${video.videoId}`);
shareVideo(`${get(instanceStore)}/watch/${video.videoId}`);
} else {
shareUrl(
shareVideo(
`${location.origin}${resolve('/watch/[videoId]', { videoId: video.videoId })}`,
'time'
);
@@ -62,7 +58,7 @@
class="row"
role="presentation"
onclick={async () => {
shareUrl(`https://redirect.invidious.io/watch?v=${video.videoId}`);
shareVideo(`https://redirect.invidious.io/watch?v=${video.videoId}`);
}}
>
<div class="min">{$_('player.share.invidiousRedirect')}</div>
@@ -72,7 +68,7 @@
class="row"
role="presentation"
onclick={async () => {
shareUrl(`https://www.youtube.com/watch?v=${video.videoId}`);
shareVideo(`https://www.youtube.com/watch?v=${video.videoId}`);
}}
>
<div class="min">{$_('player.share.youtubeLink')}</div>
@@ -2,15 +2,34 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { peerJs, removeWindowQueryFlag, setWindowQueryFlag } from '$lib/misc';
import {
determinePeerJsInstance,
peerJs,
removeWindowQueryFlag,
setWindowQueryFlag,
shareURL
} from '$lib/misc';
import type { PlayerEvents } from '$lib/player';
import { Clipboard } from '@capacitor/clipboard';
import ui from 'beercss';
import type { DataConnection } from 'peerjs';
import { onDestroy, onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { syncPartyConnectionsStore, syncPartyPeerStore } from '../store';
import z from 'zod';
import { Buffer } from 'buffer';
import { Capacitor } from '@capacitor/core';
const SyncInfo = z.object({
host: z.string(),
path: z.string(),
port: z.number(),
syncId: z.string()
});
let syncPartyManualInput: string = $state('');
const syncOrigin = Capacitor.isNativePlatform() ? 'https://sync.materialio.us' : location.origin;
function changeVideoEvent(conn: DataConnection) {
conn.on('data', (data) => {
@@ -37,6 +56,16 @@
});
}
function createShareBase64(): string {
return Buffer.from(
JSON.stringify({
...determinePeerJsInstance(),
syncId: $syncPartyPeerStore?.id
}),
'utf-8'
).toString('base64');
}
async function startWatchSync() {
if ($syncPartyPeerStore) {
$syncPartyPeerStore.destroy();
@@ -89,24 +118,36 @@
}
});
async function joinSyncParty(syncInfo: string) {
const parseInfo = SyncInfo.safeParse(
JSON.parse(Buffer.from(syncInfo, 'base64').toString('utf-8'))
);
if (parseInfo.error) {
return;
}
$syncPartyPeerStore = await peerJs(crypto.randomUUID(), parseInfo.data);
$syncPartyPeerStore.on('open', () => {
if (!$syncPartyPeerStore) return;
const conn = $syncPartyPeerStore.connect(parseInfo.data.syncId);
syncPartyConnectionsStore.set([...($syncPartyConnectionsStore || []), conn]);
changeVideoEvent(conn);
});
$syncPartyPeerStore.on('disconnected', () => {
syncPartyPeerStore.set(null);
syncPartyConnectionsStore.set(null);
});
}
onMount(async () => {
const currentUrl = get(page).url;
const syncId = currentUrl.searchParams.get('sync');
if (syncId) {
$syncPartyPeerStore = await peerJs(crypto.randomUUID());
$syncPartyPeerStore.on('open', () => {
if (!$syncPartyPeerStore) return;
const conn = $syncPartyPeerStore.connect(syncId);
syncPartyConnectionsStore.set([...($syncPartyConnectionsStore || []), conn]);
changeVideoEvent(conn);
});
$syncPartyPeerStore.on('disconnected', () => {
syncPartyPeerStore.set(null);
syncPartyConnectionsStore.set(null);
});
const syncInfo = currentUrl.searchParams.get('sync');
if (syncInfo) {
await joinSyncParty(syncInfo);
}
});
@@ -118,25 +159,23 @@
});
</script>
<dialog id="sync-party">
<h6>{$_('layout.syncParty')}</h6>
<p>{$_('layout.syncPartyWarning')}</p>
<dialog id="sync-party" class="padding">
<h6 class="no-margin">{$_('layout.syncParty')}</h6>
<p class="no-margin">{$_('layout.syncPartyWarning')}</p>
{#if $syncPartyPeerStore}
<nav>
<div class="field label border max">
<input
name="sync-share"
readonly
value={`${location.origin}${resolve(`/?sync=${$syncPartyPeerStore?.id}`, {})}`}
value={`${syncOrigin}${resolve(`/?sync=${createShareBase64()}`, {})}`}
type="text"
/>
<label class="active" for="sync-share">Share URL</label>
<label class="active" for="sync-share">{$_('layout.shareURL')}</label>
</div>
<button
onclick={async () => {
await Clipboard.write({
string: `${location.origin}${resolve(`/?sync=${$syncPartyPeerStore?.id}`, {})}`
});
await shareURL(`${syncOrigin}${resolve(`/?sync=${createShareBase64()}`, {})}`);
}}
class="square round"
>
@@ -145,6 +184,7 @@
</nav>
{/if}
<div class="space"></div>
<button
class="no-margin"
onclick={startWatchSync}
@@ -155,6 +195,29 @@
{$_('layout.startSyncParty')}
{/if}
</button>
{#if !$syncPartyPeerStore}
<div class="space"></div>
<hr />
<div class="space"></div>
<h6 class="no-margin">{$_('layout.joinSyncParty')}</h6>
<form
onsubmit={async (event) => {
event.preventDefault();
try {
await joinSyncParty(new URL(syncPartyManualInput).searchParams.get('sync') || '');
} catch {
// Continue regardless of error
}
}}
>
<div class="field label border max">
<input bind:value={syncPartyManualInput} type="text" name="sync-input" />
<label for="sync-input">{$_('layout.shareURL')}</label>
</div>
</form>
{/if}
</dialog>
<div class="snackbar" id="sync-party-connection-join">
@@ -146,6 +146,8 @@
"syncPartyWarning": "Please note your IP will be visible to users you invite.",
"startSyncParty": "Start sync party",
"endSyncParty": "End sync party",
"joinSyncParty": "Enter sync party URL",
"shareURL": "Share URL",
"notifications": "Notifications",
"noNewNotifications": "No new notifications here",
"settings": "Settings",
+39 -7
View File
@@ -3,7 +3,11 @@ import { resolve } from '$app/paths';
import he from 'he';
import type Peer from 'peerjs';
import { get } from 'svelte/store';
import { interfaceAllowInsecureRequests, isAndroidTvStore } from './store';
import {
interfaceAllowInsecureRequests,
interfaceAndroidUseNativeShare,
isAndroidTvStore
} from './store';
import type {
Channel,
HashTag,
@@ -14,6 +18,9 @@ import type {
VideoBase
} from './api/model';
import { page } from '$app/state';
import { Capacitor } from '@capacitor/core';
import { Share } from '@capacitor/share';
import { Clipboard } from '@capacitor/clipboard';
export function isVideoID(videoId: string): boolean {
const regExp = /^[a-zA-Z0-9_-]{11}$/;
@@ -46,16 +53,41 @@ export function removeWindowQueryFlag(key: string) {
}
let PeerInstance: typeof Peer;
export async function peerJs(peerId: string): Promise<Peer> {
export interface PeerInstance {
host: string;
path: string;
port: number;
}
export function determinePeerJsInstance(): PeerInstance {
return {
host: import.meta.env.VITE_DEFAULT_PEERJS_HOST || '0.peerjs.com',
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || '/',
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || 443
};
}
export async function peerJs(
id: string,
instance: PeerInstance = determinePeerJsInstance()
): Promise<Peer> {
// https://github.com/peers/peerjs/issues/819
if (typeof PeerInstance === 'undefined') {
PeerInstance = (await import('peerjs')).Peer;
}
return new PeerInstance(peerId, {
host: import.meta.env.VITE_DEFAULT_PEERJS_HOST || '0.peerjs.com',
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || '/',
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || 443
});
return new PeerInstance(id, instance);
}
export async function shareURL(url: string) {
if (
Capacitor.getPlatform() === 'android' &&
(await Share.canShare()).value &&
get(interfaceAndroidUseNativeShare)
) {
await Share.share({ url: url });
} else {
await Clipboard.write({ string: url });
}
}
export function ensureNoTrailingSlash(url: any): string {
+4 -6
View File
@@ -323,12 +323,10 @@
<Search on:searchCancelled={() => (mobileSearchShow = false)} />
</div>
{:else}
{#if !Capacitor.isNativePlatform()}
<button data-ui="#sync-party" class="circle large transparent">
<i class:primary-text={$syncPartyPeerStore}>group</i>
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
</button>
{/if}
<button data-ui="#sync-party" class="circle large transparent">
<i class:primary-text={$syncPartyPeerStore}>group</i>
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
</button>
{#if isLoggedIn}
<button class="circle large transparent" onclick={() => ui('#dialog-notifications')}
><i>notifications</i>