Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ HOST_PORT_SERVER_BETA=18080
HOST_PORT_DOWNLOADER_BETA=19093
HOST_PORT_TOKEN_BETA=18081
HOST_PORT_GARAGE_S3_BETA=3900
DATABASE_URL=jdbc:postgresql://postgres:5432/typetype
DATABASE_USER=typetype
DATABASE_PASSWORD=typetype
POSTGRES_DB=typetype
POSTGRES_USER=typetype
POSTGRES_PASSWORD=typetype
DRAGONFLY_URL=redis://dragonfly:6379
GITHUB_REPO=Priveetee/TypeType-Server
GITHUB_ISSUE_TEMPLATE=bug_report_backend.md
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/components/channel-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,30 @@ function getInitial(name: string): string {
}

export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) {
const [failed, setFailed] = useState(false);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const failed = failedSrc === src;

if (!src || failed) {
return (
<div
className={`${className} rounded-full flex-shrink-0 bg-surface-soft flex items-center justify-center text-fg-muted font-medium select-none`}
style={{ fontSize: "40%" }}
className={`${className} flex flex-shrink-0 select-none items-center justify-center rounded-full border border-border bg-gradient-to-br from-surface-strong to-surface-soft font-semibold text-fg-muted`}
title={name}
>
{getInitial(name)}
<span className="text-base leading-none">{getInitial(name)}</span>
</div>
);
}

return (
<img
src={src}
alt={name}
className={`${className} rounded-full flex-shrink-0`}
alt=""
aria-hidden="true"
className={`${className} flex-shrink-0 rounded-full object-cover`}
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
onError={() => setFailedSrc(src)}
title={name}
/>
);
}
33 changes: 20 additions & 13 deletions apps/web/src/components/history-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,21 @@ export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {

return (
<div
className="flex flex-col gap-2 group relative animate-card-pop-in"
className="group relative grid animate-card-pop-in grid-cols-[8.75rem_minmax(0,1fr)] gap-3 rounded-2xl border border-border bg-surface/45 p-2.5 sm:flex sm:flex-col sm:gap-2 sm:border-0 sm:bg-transparent sm:p-0"
style={{ animationDelay: `${delay}ms` }}
>
<Link
to="/watch"
search={watchRouteSearch(item.url)}
className="block"
className="block min-w-0 sm:w-full"
onMouseEnter={prefetch.onMouseEnter}
onMouseLeave={prefetch.onMouseLeave}
>
<div className="relative aspect-video rounded-lg overflow-hidden bg-surface-strong">
<div className="relative aspect-video overflow-hidden rounded-xl bg-surface-strong sm:rounded-lg">
<img
src={proxyImage(item.thumbnail)}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
Expand Down Expand Up @@ -93,34 +93,41 @@ export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {
</button>
</div>
</Link>
<div className="flex gap-2">
<div className="flex min-w-0 gap-2 py-0.5 sm:flex-none sm:py-0">
{item.channelUrl ? (
<ChannelRouteLink url={item.channelUrl} className="flex-shrink-0 mt-0.5">
<HistoryChannelAvatar item={item} className="w-7 h-7" />
<ChannelRouteLink url={item.channelUrl} className="mt-0.5 hidden flex-shrink-0 sm:block">
<HistoryChannelAvatar item={item} className="h-7 w-7" />
</ChannelRouteLink>
) : (
<HistoryChannelAvatar item={item} className="w-7 h-7" />
<span className="mt-0.5 hidden flex-shrink-0 sm:block">
<HistoryChannelAvatar item={item} className="h-7 w-7" />
</span>
)}
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex min-w-0 flex-col gap-1.5 sm:gap-0.5">
<Link
to="/watch"
search={watchRouteSearch(item.url)}
onMouseEnter={prefetch.onMouseEnter}
onMouseLeave={prefetch.onMouseLeave}
className="min-w-0"
>
<p className="text-sm font-medium text-fg line-clamp-2 leading-snug">{item.title}</p>
<p className="line-clamp-2 text-sm font-semibold leading-snug text-fg sm:font-medium">
{item.title}
</p>
</Link>
{item.channelUrl ? (
<ChannelRouteLink
url={item.channelUrl}
className="text-xs text-fg-muted hover:text-fg transition-colors w-fit"
className="w-fit max-w-full truncate text-xs text-fg-muted transition-colors hover:text-fg"
>
{item.channelName}
</ChannelRouteLink>
) : (
<p className="text-xs text-fg-muted">{item.channelName}</p>
<p className="truncate text-xs text-fg-muted">{item.channelName}</p>
)}
<p className="text-[11px] text-fg-soft">Watched {formatWatchedAt(item.watchedAt)}</p>
<p className="line-clamp-1 text-[11px] text-fg-soft sm:mt-0">
Watched {formatWatchedAt(item.watchedAt)}
</p>
</div>
</div>
</div>
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/history-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function HistoryFilter({
};

return (
<aside className="order-first flex w-full flex-shrink-0 flex-col gap-4 lg:order-none lg:sticky lg:top-20 lg:w-52 lg:self-start lg:gap-5">
<aside className="order-first flex w-full flex-shrink-0 flex-col gap-4 rounded-2xl border border-border bg-surface/40 p-3 lg:order-none lg:sticky lg:top-20 lg:w-52 lg:self-start lg:gap-5 lg:border-0 lg:bg-transparent lg:p-0">
<div>
<div className="mb-2.5 flex items-center justify-between gap-3">
<p className="text-[11px] text-fg-soft uppercase tracking-wider">
Expand All @@ -101,7 +101,7 @@ export function HistoryFilter({
<button
type="button"
onClick={onClearHistory}
className="text-[11px] text-danger transition-colors hover:text-danger-strong"
className="rounded-full border border-danger/40 px-2.5 py-1 text-[11px] text-danger transition-colors hover:border-danger hover:text-danger-strong"
>
Clear all
</button>
Expand All @@ -123,13 +123,13 @@ export function HistoryFilter({

<div>
<p className="text-[11px] text-fg-soft uppercase tracking-wider mb-2">Date</p>
<div className="grid grid-cols-2 gap-1 lg:flex lg:flex-col lg:gap-0.5">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 lg:flex lg:flex-col lg:gap-0.5">
{PRESET_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handlePreset(opt.value)}
className={`h-8 px-2.5 rounded-lg text-xs text-left transition-colors ${
className={`h-8 rounded-lg px-2.5 text-left text-xs transition-colors sm:text-center lg:text-left ${
isPresetActive(opt.value)
? "bg-fg text-app font-medium"
: "text-fg-muted hover:text-fg hover:bg-surface-strong"
Expand All @@ -142,7 +142,7 @@ export function HistoryFilter({
<button
type="button"
onClick={handleOlderToggle}
className={`col-span-2 h-8 px-2.5 rounded-lg text-xs text-left transition-colors ${
className={`h-8 rounded-lg px-2.5 text-left text-xs transition-colors sm:text-center lg:text-left ${
olderActive
? "bg-fg text-app font-medium"
: "text-fg-muted hover:text-fg hover:bg-surface-strong"
Expand Down
26 changes: 0 additions & 26 deletions apps/web/src/components/media-session-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ export function MediaSessionSync({
const paused = useMediaState("paused");
const currentTimeRef = useRef(0);
const durationRef = useRef(0);
const pausedRef = useRef(true);

pausedRef.current = paused;

useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
Expand Down Expand Up @@ -99,29 +96,6 @@ export function MediaSessionSync({
};
}, [canSeek, isLive, onPreviousTrack, onNextTrack, remote]);

useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
const onVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (!pausedRef.current) {
void Promise.resolve(remote.play()).catch(() => {});
}
};
const onFocus = () => {
if (!pausedRef.current) {
void Promise.resolve(remote.play()).catch(() => {});
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pageshow", onFocus);
window.addEventListener("focus", onFocus);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pageshow", onFocus);
window.removeEventListener("focus", onFocus);
};
}, [remote]);

useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = paused ? "paused" : "playing";
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/playback-return-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useRef } from "react";
import { useMediaPlayer, useMediaRemote } from "../lib/vidstack";

export function PlaybackReturnGuard() {
const player = useMediaPlayer();
const remote = useMediaRemote();
const savedTimeRef = useRef(0);
const wasPlayingRef = useRef(false);
const restoreTokenRef = useRef(0);

useEffect(() => {
const root = player?.el;
if (!root) return;
const rootElement = root;

function media() {
return rootElement.querySelector<HTMLMediaElement>("video,audio");
}

function remember() {
const current = media();
if (!current) return;
const currentTime = Number.isFinite(current.currentTime) ? current.currentTime : 0;
const duration = Number.isFinite(current.duration) ? current.duration : 0;
if (currentTime < 5 || (duration > 0 && currentTime >= duration * 0.95)) return;
savedTimeRef.current = currentTime;
wasPlayingRef.current = !current.paused && !current.ended;
restoreTokenRef.current += 1;
}

function restore() {
const token = restoreTokenRef.current;
window.setTimeout(() => {
if (token !== restoreTokenRef.current) return;
const current = media();
const savedTime = savedTimeRef.current;
if (!current || savedTime < 5) return;
const currentTime = Number.isFinite(current.currentTime) ? current.currentTime : 0;
const duration = Number.isFinite(current.duration) ? current.duration : 0;
if (duration > 0 && savedTime >= duration * 0.95) return;
if (currentTime < savedTime - 3) remote.seek(savedTime);
if (wasPlayingRef.current && current.paused && !current.ended) {
void Promise.resolve(remote.play()).catch(() => {});
}
}, 250);
}

function onVisibilityChange() {
if (document.visibilityState === "hidden") remember();
if (document.visibilityState === "visible") restore();
}

document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pagehide", remember);
window.addEventListener("pageshow", restore);
window.addEventListener("focus", restore);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pagehide", remember);
window.removeEventListener("pageshow", restore);
window.removeEventListener("focus", restore);
};
}, [player, remote]);

return null;
}
80 changes: 52 additions & 28 deletions apps/web/src/components/subscription-channel-list.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,64 @@
import { formatExactDate } from "../lib/format";
import { startTransition, useMemo, useState } from "react";
import { proxyImage } from "../lib/proxy";
import type { SubscriptionItem } from "../types/user";
import { ChannelAvatar } from "./channel-avatar";
import { ChannelRouteLink } from "./channel-route-link";
import { ScrollSentinel } from "./scroll-sentinel";

type Props = {
subscriptions: SubscriptionItem[];
};
type Props = { subscriptions: SubscriptionItem[] };

function subscribedLabel(timestamp: number): string {
const date = formatExactDate(timestamp);
return date ? `Subscribed ${date}` : "Subscribed";
}
const CHANNEL_BATCH_SIZE = 25;

export function SubscriptionChannelList({ subscriptions }: Props) {
const sorted = [...subscriptions].sort((a, b) => a.name.localeCompare(b.name));
const [visibleCount, setVisibleCount] = useState(CHANNEL_BATCH_SIZE);
const sorted = useMemo(
() => [...subscriptions].sort((a, b) => a.name.localeCompare(b.name)),
[subscriptions],
);
const visible = sorted.slice(0, Math.min(visibleCount, sorted.length));
const hasMore = visibleCount < sorted.length;

function loadMore() {
startTransition(() => {
setVisibleCount((count) => Math.min(count + CHANNEL_BATCH_SIZE, sorted.length));
});
}

return (
<section className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{sorted.map((item) => (
<ChannelRouteLink
key={item.channelUrl}
url={item.channelUrl}
className="group flex min-w-0 items-center gap-3 rounded-xl border border-border bg-surface/60 p-3 transition-colors hover:border-border-strong hover:bg-surface"
>
<ChannelAvatar src={proxyImage(item.avatarUrl)} name={item.name} className="h-12 w-12" />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-fg group-hover:text-fg-strong">
{item.name}
</span>
<span className="mt-0.5 block truncate text-xs text-fg-soft">
{subscribedLabel(item.subscribedAt)}
</span>
</span>
</ChannelRouteLink>
))}
</section>
<>
<section className="grid grid-cols-3 gap-x-3 gap-y-6 sm:grid-cols-4 lg:grid-cols-5">
{visible.map((item, index) => (
<div
key={item.channelUrl}
className="animate-card-pop-in"
style={{ animationDelay: `${Math.min((index % CHANNEL_BATCH_SIZE) * 16, 160)}ms` }}
>
<ChannelRouteLink
url={item.channelUrl}
className="group flex min-w-0 flex-col items-center gap-2 rounded-2xl px-1.5 py-2 text-center transition-colors hover:bg-surface/55"
>
<span className="rounded-full p-1 transition-colors group-hover:bg-surface-strong/70">
<ChannelAvatar
src={proxyImage(item.avatarUrl)}
name={item.name}
className="h-16 w-16 sm:h-[4.5rem] sm:w-[4.5rem]"
/>
</span>
<span className="flex min-w-0 flex-col items-center">
<span className="line-clamp-2 text-sm font-medium leading-snug text-fg group-hover:text-fg-strong">
{item.name}
</span>
</span>
</ChannelRouteLink>
</div>
))}
</section>
{hasMore && (
<div className="pt-2 text-center text-[11px] text-fg-soft">
Showing {visible.length} of {sorted.length} channels
</div>
)}
<ScrollSentinel enabled={hasMore} onIntersect={loadMore} />
</>
);
}
Loading