+
+
+
diff --git a/src/renderer/src/lib/components/players/Twitch.svelte b/src/renderer/src/lib/components/players/Twitch.svelte
new file mode 100644
index 0000000..f8e452d
--- /dev/null
+++ b/src/renderer/src/lib/components/players/Twitch.svelte
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/lib/components/players/YouTube.svelte b/src/renderer/src/lib/components/players/YouTube.svelte
new file mode 100644
index 0000000..5dc9a41
--- /dev/null
+++ b/src/renderer/src/lib/components/players/YouTube.svelte
@@ -0,0 +1,47 @@
+
+
+{#if usingEmbed}
+
+
+
+
+
+{:else}
+
+
+ {#if player.isLive}
+ {#if player.live.hls}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+
+
+
+{/if}
diff --git a/src/renderer/src/lib/index.ts b/src/renderer/src/lib/index.ts
new file mode 100644
index 0000000..de05d8c
--- /dev/null
+++ b/src/renderer/src/lib/index.ts
@@ -0,0 +1,74 @@
+import { Platform } from '$shared/enums'
+
+const ytAvatarCache = new Map()
+const twAvatarCache = new Map()
+
+export function getAvatarUrl(platform: Platform, username: string, avatar: Uint8Array): string {
+ const cache = platform === Platform.Twitch ? twAvatarCache : ytAvatarCache
+ if (cache.has(username)) {
+ return cache.get(username)
+ }
+
+ const blob = new Blob([avatar], { type: 'image/png' })
+ const url = URL.createObjectURL(blob)
+
+ cache.set(username, url)
+ return url
+}
+
+export function defaultSettings(): Settings {
+ return {
+ videos: {
+ autoplay: true,
+ useEmbed: true
+ },
+ streams: {
+ blockAds: true
+ }
+ }
+}
+
+export function timeAgo(timestamp: number): string {
+ const now = Math.floor(Date.now() / 1000)
+ const secondsAgo = now - timestamp
+
+ if (secondsAgo <= 0) return 'just now'
+
+ if (secondsAgo < 60) return `${secondsAgo} second${plural(secondsAgo)} ago`
+ const minutesAgo = Math.floor(secondsAgo / 60)
+
+ if (minutesAgo < 60) return `${minutesAgo} minute${plural(minutesAgo)} ago`
+ const hoursAgo = Math.floor(minutesAgo / 60)
+
+ if (hoursAgo < 24) return `${hoursAgo} hour${plural(hoursAgo)} ago`
+
+ const daysAgo = Math.floor(hoursAgo / 24)
+ if (daysAgo < 30) return `${daysAgo} day${plural(daysAgo)} ago`
+
+ const monthsAgo = Math.floor(daysAgo / 30)
+ if (monthsAgo < 12) return `${monthsAgo} month${plural(monthsAgo)} ago`
+
+ const yearsAgo = Math.floor(monthsAgo / 12)
+ return `${yearsAgo} year${plural(yearsAgo)} ago`
+}
+
+export function streamingFor(startedAt: string): string {
+ const diff = new Date().getTime() - new Date(startedAt).getTime()
+ const totalSeconds = Math.floor(diff / 1000)
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+
+ const formattedMinutes = minutes.toString().padStart(2, '0')
+ const formattedSeconds = seconds.toString().padStart(2, '0')
+
+ return `${hours}:${formattedMinutes}:${formattedSeconds}`
+}
+
+function plural(number: number): string {
+ if (number > 1) {
+ return 's'
+ }
+
+ return ''
+}
diff --git a/src/renderer/src/lib/state/View.svelte.ts b/src/renderer/src/lib/state/View.svelte.ts
new file mode 100644
index 0000000..4389653
--- /dev/null
+++ b/src/renderer/src/lib/state/View.svelte.ts
@@ -0,0 +1,59 @@
+import { View } from '$shared/enums'
+
+type CurrentView = {
+ id: View
+ name: string
+ route: string
+}
+
+// eslint-disable-next-line prefer-const
+export let currentView: CurrentView = $state({ id: View.Videos, name: 'Videos', route: '/videos' })
+
+export function changeView(newViewID: View, navigateURL = true, path?: string): void {
+ switch (newViewID) {
+ case View.Videos:
+ localStorage.setItem('lastView', newViewID)
+
+ currentView.id = View.Videos
+ currentView.name = 'Videos'
+ if (navigateURL) {
+ navigate(`/videos${path ? `${path}` : ''}`)
+ }
+ break
+
+ case View.Streams:
+ localStorage.setItem('lastView', newViewID)
+
+ currentView.id = View.Streams
+ currentView.name = 'Streams'
+ if (navigateURL) {
+ navigate(`/streams${path ? `${path}` : ''}`)
+ }
+ break
+
+ case View.Users:
+ localStorage.setItem('lastView', newViewID)
+
+ currentView.id = View.Users
+ currentView.name = 'Users'
+ if (navigateURL) {
+ navigate('/users')
+ }
+ break
+
+ case View.Settings:
+ localStorage.setItem('lastView', newViewID)
+
+ currentView.id = View.Settings
+ currentView.name = 'Settings'
+ if (navigateURL) {
+ navigate('/settings')
+ }
+ break
+ }
+}
+
+function navigate(route: string): void {
+ window.history.pushState({}, '', route)
+ currentView.route = route
+}
diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts
new file mode 100644
index 0000000..d79b10a
--- /dev/null
+++ b/src/renderer/src/main.ts
@@ -0,0 +1,11 @@
+import { mount } from 'svelte'
+
+import './app.css'
+
+import App from './App.svelte'
+
+const app = mount(App, {
+ target: document.getElementById('app')!
+})
+
+export default app
diff --git a/src/renderer/src/pages/Settings.svelte b/src/renderer/src/pages/Settings.svelte
new file mode 100644
index 0000000..ab00c76
--- /dev/null
+++ b/src/renderer/src/pages/Settings.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+ Videos
+
+
+
+
+
+
+
+ Streams
+
+
+
+
+
+
diff --git a/src/renderer/src/pages/Users.svelte b/src/renderer/src/pages/Users.svelte
new file mode 100644
index 0000000..1d57031
--- /dev/null
+++ b/src/renderer/src/pages/Users.svelte
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if filter === Platform.YouTube}
+
+ {/if}
+
+
+
+
+
+ {#if !loading && filteredUsers.filter((user) => user.platform === filter).length === 0}
+
No users found
+ {:else}
+
+ {#each filteredUsers as user, index (index)}
+
+

+
+
+
{user.display_name}
+
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/renderer/src/pages/streams/Streams.svelte b/src/renderer/src/pages/streams/Streams.svelte
new file mode 100644
index 0000000..efb1113
--- /dev/null
+++ b/src/renderer/src/pages/streams/Streams.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+
loading} />
+
+
+
+ {#if !loading && feed.length === 0}
+ No streams found
+ {:else}
+
+ {#each feed as live_now, index (index)}
+
+ {/each}
+
+ {/if}
+
+
diff --git a/src/renderer/src/pages/streams/Watch.svelte b/src/renderer/src/pages/streams/Watch.svelte
new file mode 100644
index 0000000..fb5561f
--- /dev/null
+++ b/src/renderer/src/pages/streams/Watch.svelte
@@ -0,0 +1,87 @@
+
+
+
+ {#if loading}
+
+ {:else if url}
+
+
+
+
+
+
+
+
+ {:else}
+
+ {`${username} is not live`}
+
+ {/if}
+
+
+{#if !loading && url && movingMouse && !showChat}
+
+{/if}
diff --git a/src/renderer/src/pages/videos/Videos.svelte b/src/renderer/src/pages/videos/Videos.svelte
new file mode 100644
index 0000000..6b2727a
--- /dev/null
+++ b/src/renderer/src/pages/videos/Videos.svelte
@@ -0,0 +1,158 @@
+
+
+
+
+
loading} />
+
+
+
+ {#if !loading && feed.length === 0}
+ No videos found
+ {:else}
+
+ {#each feed as video, index (index)}
+
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/src/renderer/src/pages/videos/Watch.svelte b/src/renderer/src/pages/videos/Watch.svelte
new file mode 100644
index 0000000..03b737d
--- /dev/null
+++ b/src/renderer/src/pages/videos/Watch.svelte
@@ -0,0 +1,163 @@
+
+
+
+ {#if loading}
+
+ {:else}
+
+ {#key usingEmbed}
+ {#if player && player.id}
+
+ {/if}
+ {/key}
+
+
+
+
+
+
+
{player.info.title}
+
+
+ {player.isLive ? 'Live now' : player.info.published_date_txt} - {player.info
+ .view_count
+ ? `${player.info.view_count.toLocaleString()} views`
+ : ''}
+
+
+
+
+

+
+
+ {player.channel.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if player.info.description}
+
+ {@html player.info.description}
+ {:else}
+ No description available
+ {/if}
+
+
+ {/if}
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
deleted file mode 100644
index ceccaaf..0000000
--- a/src/routes/+layout.server.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const prerender = true;
-export const ssr = false;
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
deleted file mode 100644
index bc806e5..0000000
--- a/src/routes/+layout.svelte
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
- {@render children()}
-
-
-
-
-
-
-
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
deleted file mode 100644
index b4652a7..0000000
--- a/src/routes/+page.svelte
+++ /dev/null
@@ -1,78 +0,0 @@
-
diff --git a/src/routes/streams/+page.svelte b/src/routes/streams/+page.svelte
deleted file mode 100644
index 3e2374d..0000000
--- a/src/routes/streams/+page.svelte
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
- {#if !loading && feed.length === 0}
-
No streams found
- {:else}
-
- {#each feed as live_now, index (index)}
-
- {/each}
-
- {/if}
-
diff --git a/src/routes/streams/watch/+page.svelte b/src/routes/streams/watch/+page.svelte
deleted file mode 100644
index b91e51b..0000000
--- a/src/routes/streams/watch/+page.svelte
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
-
- {#if loading}
-
- {:else if url}
-
-
-
-
-
-
-
- {:else}
-
- {`${username} is not live`}
-
- {/if}
-
-
-{#if !loading && url && movingMouse && !showChat}
-
-{/if}
diff --git a/src/routes/users/+page.svelte b/src/routes/users/+page.svelte
deleted file mode 100644
index 56afbf8..0000000
--- a/src/routes/users/+page.svelte
+++ /dev/null
@@ -1,205 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {#if filter === Platform.YouTube}
-
- {/if}
-
-
-
-
-
- {#if !loading && filteredUsers.filter((user) => user.platform === filter).length === 0}
-
No users found
- {:else}
-
- {#each filteredUsers as user, index (index)}
- {#if user.platform === filter}
-
-

-
-
-
{user.username}
-
-
-
-
-
-
-
-
- {/if}
- {/each}
-
- {/if}
-
-
diff --git a/src/routes/videos/+page.svelte b/src/routes/videos/+page.svelte
deleted file mode 100644
index 875d904..0000000
--- a/src/routes/videos/+page.svelte
+++ /dev/null
@@ -1,132 +0,0 @@
-
-
-
- {#if !loading && feed.length === 0}
-
No videos found
- {:else}
-
- {#each feed as video, index (index)}
-
- {/each}
-
- {/if}
-
-
-
diff --git a/src/routes/videos/watch/+page.svelte b/src/routes/videos/watch/+page.svelte
deleted file mode 100644
index 66816ca..0000000
--- a/src/routes/videos/watch/+page.svelte
+++ /dev/null
@@ -1,176 +0,0 @@
-
-
-
- {#if loading}
-
- {:else}
-
- {#key usingEmbed}
-
- {/key}
-
-
-
-
-
-
-
{player.metadata.title}
-
-
- {player.metadata.publishedDateTxt}
- -
- {player.metadata.viewCount ? `${player.metadata.viewCount} views` : ''}
-
-
-
-
-

-
-
- {player.channel.name}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#if player.metadata.description}
-
- {@html getDescription()}
- {:else}
- No description available
- {/if}
-
-
- {/if}
-
diff --git a/src/shared/enums.ts b/src/shared/enums.ts
new file mode 100644
index 0000000..9ee73d9
--- /dev/null
+++ b/src/shared/enums.ts
@@ -0,0 +1,11 @@
+export enum View {
+ Videos = 'videos',
+ Streams = 'streams',
+ Users = 'users',
+ Settings = 'settings'
+}
+
+export enum Platform {
+ Twitch = 'twitch',
+ YouTube = 'youtube'
+}
diff --git a/src/shared/globals.d.ts b/src/shared/globals.d.ts
new file mode 100644
index 0000000..9f3bae8
--- /dev/null
+++ b/src/shared/globals.d.ts
@@ -0,0 +1,110 @@
+import type { Database } from 'better-sqlite3'
+
+declare global {
+ type Migration = {
+ version: number
+ up(db: Database): void
+ description: string
+ }
+
+ type Settings = {
+ videos: {
+ autoplay: boolean
+ useEmbed: boolean
+ }
+ streams: {
+ blockAds: boolean
+ }
+ }
+
+ type User = {
+ id: string
+ // Used in links like 'twitch.tv/username' or youtube.com/@username'
+ username: string
+ display_name: string
+ platform: Platform
+ avatar: Uint8Array
+ }
+
+ type Feed = {
+ twitch: LiveNow[] | null
+ youtube: FeedVideo[] | null
+ }
+
+ type LiveNow = {
+ username: string
+ started_at: string
+ }
+
+ type FeedVideo = {
+ id: string
+ username: string
+ title: string
+ published_at: number
+ view_count: string
+ }
+
+ type WatchPageVideo = {
+ id: string
+ isLive: boolean
+ live: WatchPageVideoLive
+ channel: WatchPageVideoChannel
+ info: WatchPageVideoInfo
+ dash?: string
+ }
+
+ type WatchPageVideoChannel = {
+ id: string
+ name: string
+ avatar: string
+ }
+
+ type WatchPageVideoLive = {
+ hls?: string
+ dash?: string
+ }
+
+ type WatchPageVideoInfo = {
+ title: string
+ description: string
+ published_date_txt: string
+ view_count: number
+ }
+
+ type ChatEvent = {
+ event: 'message'
+ data: ChatMessage
+ }
+
+ type ChatMessage = {
+ id: number
+ // Color
+ c: string
+ // First message, not used
+ f: boolean
+ // Name
+ n: string
+ // Fragments that make up the message
+ m: MessageFragment[]
+ }
+
+ type MessageFragment = {
+ // export type, 0 = text, 1 = emote, 2 = url
+ t: number
+ // Content
+ c: string
+ // Emote
+ e: Emote
+ }
+
+ type Emote = {
+ // Name
+ n: string
+ // URL
+ u: string
+ // Width
+ w: number
+ // Height
+ h: number
+ }
+}
diff --git a/static/favicon.png b/static/favicon.png
deleted file mode 100644
index 825b9e6..0000000
Binary files a/static/favicon.png and /dev/null differ
diff --git a/svelte.config.js b/svelte.config.js
deleted file mode 100644
index ae784dd..0000000
--- a/svelte.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import adapter from '@sveltejs/adapter-static';
-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- preprocess: vitePreprocess(),
- kit: {
- adapter: adapter()
- }
-};
-
-export default config;
diff --git a/svelte.config.mjs b/svelte.config.mjs
new file mode 100644
index 0000000..df7b91d
--- /dev/null
+++ b/svelte.config.mjs
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+export default {
+ preprocess: vitePreprocess()
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 3fca1e3..8b112dd 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,3 +1,2 @@
-module.exports = {
- content: ['./src/**/*.{html,js,svelte}']
-};
+/** @type {import('tailwindcss').Config} */
+export const content = ['./src/renderer/**/*.svelte']
diff --git a/tsconfig.json b/tsconfig.json
index b8ccd8a..79627e7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,16 +1,11 @@
{
- "extends": "./.svelte-kit/tsconfig.json",
- "compilerOptions": {
- "allowJs": true,
- "checkJs": true,
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "sourceMap": true,
- "strict": true,
- "moduleResolution": "bundler",
- "types": ["vidstack/svelte", "svelte", "@sveltejs/kit"]
- },
- "include": ["src/**/*", "src/app.d.ts"]
-}
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.web.json"
+ }
+ ],
+}
\ No newline at end of file
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..4c53001
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
+ "include": [
+ "./electron.vite.config.ts",
+ "./src/main/**/*",
+ "./src/preload/**/*",
+ "./src/shared/**/*"
+ ],
+ "compilerOptions": {
+ "composite": true,
+ "moduleResolution": "bundler",
+ "types": [
+ "electron-vite/node",
+ "./src/shared/globals",
+ ],
+ "paths": {
+ "$shared/*": [
+ "./src/shared/*"
+ ],
+ "$shared": [
+ "./src/shared"
+ ],
+ }
+ }
+}
\ No newline at end of file
diff --git a/tsconfig.web.json b/tsconfig.web.json
new file mode 100644
index 0000000..9f6d7af
--- /dev/null
+++ b/tsconfig.web.json
@@ -0,0 +1,44 @@
+{
+ "extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
+ "include": [
+ "./src/renderer/src/env.d.ts",
+ "./src/renderer/src/**/*",
+ "./src/renderer/src/**/*.svelte",
+ "./src/renderer/src/**/*.svelte.ts",
+ "./src/preload/*.d.ts",
+ "./src/shared/**/*.ts",
+ "./src/shared/**/*.d.ts"
+ ],
+ "compilerOptions": {
+ "composite": true,
+ "verbatimModuleSyntax": true,
+ "useDefineForClassFields": true,
+ "strict": false,
+ "allowJs": true,
+ "checkJs": true,
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "types": [
+ "vidstack/svelte",
+ "svelte",
+ "./src/shared/globals",
+ ],
+ "paths": {
+ "$lib/*": [
+ "./src/renderer/src/lib/*"
+ ],
+ "$lib": [
+ "./src/renderer/src/lib"
+ ],
+ "$shared/*": [
+ "./src/shared/*"
+ ],
+ "$shared": [
+ "./src/shared"
+ ],
+ }
+ }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
deleted file mode 100644
index e3f3d85..0000000
--- a/vite.config.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { sveltekit } from '@sveltejs/kit/vite';
-import { defineConfig } from 'vite';
-
-import tailwindcss from '@tailwindcss/vite';
-import { vite as vidstack } from 'vidstack/plugins';
-
-// @ts-expect-error process is a nodejs global
-const host = process.env.TAURI_DEV_HOST;
-
-// https://vitejs.dev/config/
-export default defineConfig(async () => ({
- plugins: [vidstack(), sveltekit(), tailwindcss()],
-
- // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
- //
- // 1. prevent vite from obscuring rust errors
- clearScreen: false,
- // 2. tauri expects a fixed port, fail if that port is not available
- server: {
- port: 5173,
- strictPort: true,
- host: host || false,
- hmr: host
- ? {
- protocol: 'ws',
- host,
- port: 5174
- }
- : undefined,
- watch: {
- // 3. tell vite to ignore watching `src-tauri`
- ignored: ['**/src-tauri/**']
- }
- }
-}));