Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
7e7e903
fix: support YouTube Shorts path redirects
Priveetee Aug 6, 2026
4d983a7
chore: prepare frontend 1.4.0
Priveetee Aug 6, 2026
88ed482
feat: add notification toast preview
Priveetee Aug 7, 2026
8441f2e
style: compact notification toast preview
Priveetee Aug 7, 2026
233b5c8
feat: track unseen notification uploads
Priveetee Aug 7, 2026
9b5d7d4
feat: show new subscription upload toasts
Priveetee Aug 7, 2026
227ea12
fix: preserve resumed sabr playback position
Priveetee Aug 7, 2026
e3677e5
fix: stabilize safari sabr resume playback
Priveetee Aug 7, 2026
634fee3
fix: update vulnerable frontend build dependencies
Priveetee Aug 8, 2026
2ef95cc
chore: update frontend dependencies
Priveetee Aug 8, 2026
7bba449
feat: add RSS frontend API
Priveetee Aug 9, 2026
868a9d8
feat: add RSS feed editor fields
Priveetee Aug 9, 2026
90cc53c
feat: add RSS feed editor dialogs
Priveetee Aug 9, 2026
d3760d3
feat: add RSS feed controls
Priveetee Aug 9, 2026
51251ca
feat: add RSS account settings
Priveetee Aug 9, 2026
484c311
feat: add RSS administration controls
Priveetee Aug 9, 2026
cee70fc
feat: add RSS administration section
Priveetee Aug 9, 2026
a103f4c
feat: add RSS creation shortcuts
Priveetee Aug 9, 2026
564367b
test: cover RSS frontend contracts
Priveetee Aug 9, 2026
b3a0670
fix: proxy RSS feed collection
Priveetee Aug 9, 2026
2ea43d4
feat: add subscription live stream visibility setting
Priveetee Aug 9, 2026
6115928
chore: reconcile main into dev
Priveetee Aug 10, 2026
79ca93f
fix: load YouTube subtitles in the browser
Priveetee Aug 11, 2026
b45add8
chore: align development version with 1.5.0
Priveetee Aug 11, 2026
9309e2a
fix: preserve resumed playback position
Priveetee Aug 12, 2026
84a9c1e
fix: complete initial OIDC bootstrap login
Priveetee Aug 12, 2026
282237f
feat: hide members-only content
Priveetee Aug 12, 2026
67b3de9
fix: show canonical video quality labels
Priveetee Aug 14, 2026
d8a57d2
fix: stop blocked autoplay retries
Priveetee Aug 14, 2026
fca59bd
chore: update mse player to 0.1.45
Priveetee Aug 14, 2026
21350bf
fix: normalize portrait quality tiers
Priveetee Aug 14, 2026
5c4242b
chore: update mse player to 0.1.46
Priveetee Aug 14, 2026
d46ede2
fix: prevent pending autoplay retries
Priveetee Aug 14, 2026
84b4fc1
chore: align beta version with 1.5.1
Priveetee Aug 14, 2026
0830bb6
ci: allow released base versions on dev
Priveetee Aug 14, 2026
27f6ce2
fix: cancel stalled Safari autoplay
Priveetee Aug 14, 2026
c3ed9ad
fix: keep autoplay deadline syntax erasable
Priveetee Aug 14, 2026
4f7e240
fix: shorten stalled autoplay deadline
Priveetee Aug 14, 2026
133653a
fix: ignore expired Safari autoplay success
Priveetee Aug 14, 2026
4df067b
fix: block late Safari autoplay events
Priveetee Aug 14, 2026
802a491
fix: scope Safari autoplay activation
Priveetee Aug 14, 2026
484f0a1
fix: defer Safari playback until MSE is ready
Priveetee Aug 14, 2026
4d4f028
fix: scope Safari playback permission to player
Priveetee Aug 14, 2026
f2ff9f9
fix: cancel stalled Safari playback
Priveetee Aug 14, 2026
999bdde
fix: preserve transient playback after autoplay expiry
Priveetee Aug 14, 2026
f8b5dd5
chore: update mse player to 0.1.48
Priveetee Aug 14, 2026
30f9bf4
fix: allow touch playback after autoplay expiry
Priveetee Aug 14, 2026
7b950b2
fix: refresh OIDC bootstrap status
Priveetee Aug 15, 2026
7eadd6d
feat: add notification popup setting
Priveetee Aug 15, 2026
80763e3
chore: update mse player to 0.1.49
Priveetee Aug 15, 2026
9922993
fix: guide gated playback to YouTube login
Priveetee Aug 15, 2026
7098e78
fix: resume playback after hidden tab suspension
Priveetee Aug 17, 2026
ecd67a7
fix: serialize playback progress updates
Priveetee Aug 21, 2026
c5635f3
fix: refresh progress before playback resume
Priveetee Aug 21, 2026
0b9922d
fix: expire cached playback progress
Priveetee Aug 21, 2026
be082df
fix: preserve progress through MSE transitions
Priveetee Aug 21, 2026
a4e8b06
chore: prepare frontend 1.6.0
Priveetee Aug 22, 2026
c4f1934
chore: reconcile main for 1.6.0
Priveetee Aug 22, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
exit 1
fi

if [[ "$GITHUB_REF" == "refs/heads/dev" ]]; then
exit 0
fi

tag="v$version"
tag_sha="$(git ls-remote --tags origin "refs/tags/$tag" | cut -f1)"
if [[ -z "$tag_sha" ]]; then
Expand Down
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@typetype/web",
"private": true,
"version": "1.5.1",
"version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -13,7 +13,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@typetype/mse": "0.1.44",
"@typetype/mse": "0.1.49",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/embed-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type EmbedErrorProps = {
image?: string;
availability?: VideoAvailability;
poster?: string;
watchUrl?: string;
};

export function EmbedError({
Expand All @@ -24,6 +25,7 @@ export function EmbedError({
image,
availability,
poster,
watchUrl,
}: EmbedErrorProps) {
const availabilityCopy = availability ? videoAvailabilityCopy(availability, message) : null;
const displayedMessage = availabilityCopy?.message ?? message;
Expand Down Expand Up @@ -84,6 +86,16 @@ export function EmbedError({
Retry
</button>
)}
{watchUrl && (
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="px-5 py-2 rounded-full bg-white hover:bg-fg text-app text-sm font-medium transition-colors cursor-pointer"
>
Connect YouTube on TypeType
</a>
)}
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/format-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useDashPlayerSnapshot } from "../lib/dash-player-store";
import { dashTrackGroups, maxTrackHeight, selectDashTrack } from "../lib/dash-video";
import { activeFamily, type CodecFamily, codecFamily, groupByFamily } from "../lib/quality-utils";
import {
maxSabrCodecHeight,
maxSabrCodecLabel,
sabrCodecOptions,
selectSabrCodec,
} from "../lib/sabr-quality-selection";
Expand Down Expand Up @@ -60,7 +60,7 @@ export function FormatSelector() {
<DefaultMenuRadioGroup
value={selected.codec}
options={codecs.map((codec) => ({
label: `${codec} ${maxSabrCodecHeight(sabrOptions, codec)}p`,
label: `${codec} ${maxSabrCodecLabel(sabrOptions, codec)}`,
value: codec,
}))}
onChange={onSabrChange}
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/components/media-progress-events.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { recordClientEvent } from "../lib/client-debug-log";
import { isSabrPlaybackEventTransient } from "../lib/sabr-vidstack-bridge";
import { resolveMediaProgressPosition } from "../lib/media-progress-position";
import { consumeSabrSeekTarget, isSabrPlaybackEventTransient } from "../lib/sabr-vidstack-bridge";
import { useMediaPlayer } from "../lib/vidstack";

type Props = {
Expand Down Expand Up @@ -58,7 +59,18 @@ export function MediaProgressEvents({
const media = rootElement.querySelector<HTMLMediaElement>("video,audio");
if (!media) return false;

const update = () => onTimeUpdateRef.current?.(toPositionMs(media));
const video = media instanceof HTMLVideoElement ? media : null;
const reportPosition = (requestedPositionMs: number | null = null) => {
const positionMs = resolveMediaProgressPosition(
video,
toPositionMs(media),
requestedPositionMs,
);
if (positionMs === null) return false;
onTimeUpdateRef.current?.(positionMs);
return true;
};
const update = () => reportPosition();
const readPosition = () => toPositionMs(media);
const suppressPlaybackEvent = () =>
suppressPlaybackEventsRef.current ||
Expand All @@ -74,11 +86,12 @@ export function MediaProgressEvents({
onPauseRef.current?.();
};
const seeked = () => {
update();
const requestedPositionMs = video ? consumeSabrSeekTarget(video) : null;
if (!reportPosition(requestedPositionMs)) return;
onSeekedRef.current?.();
};
const seeking = () => {
update();
if (!update()) return;
onSeekingRef.current?.(toPositionMs(media));
};
const ended = () => {
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/components/media-session-sync.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge";
import { useMediaRemote, useMediaState } from "../lib/vidstack";
import { MediaSessionPositionSync } from "./media-session-position-sync";

Expand All @@ -8,6 +9,7 @@ type Props = {
artwork?: string;
canSeek?: boolean;
isLive?: boolean;
sabrVideo?: HTMLVideoElement | null;
onPreviousTrack?: () => void;
onNextTrack?: () => void;
};
Expand All @@ -28,6 +30,7 @@ export function MediaSessionSync({
artwork,
canSeek = true,
isLive = false,
sabrVideo = null,
onPreviousTrack,
onNextTrack,
}: Props) {
Expand Down Expand Up @@ -55,11 +58,17 @@ export function MediaSessionSync({
useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
const session = navigator.mediaSession;
const setPlayback = (playing: boolean) => {
const request = sabrVideo
? requestSabrVidstackPlayback(sabrVideo, playing, true)
: Promise.resolve(playing ? remote.play() : remote.pause());
void request.catch(() => {});
};
safeSetActionHandler(session, "play", () => {
void Promise.resolve(remote.play()).catch(() => {});
setPlayback(true);
});
safeSetActionHandler(session, "pause", () => {
void Promise.resolve(remote.pause()).catch(() => {});
setPlayback(false);
});
if (canSeek) {
safeSetActionHandler(session, "seekbackward", (details) => {
Expand All @@ -80,7 +89,7 @@ export function MediaSessionSync({
});
}
safeSetActionHandler(session, "stop", () => {
void Promise.resolve(remote.pause()).catch(() => {});
setPlayback(false);
});
safeSetActionHandler(session, "previoustrack", isLive ? null : (onPreviousTrack ?? null));
safeSetActionHandler(session, "nexttrack", isLive ? null : (onNextTrack ?? null));
Expand All @@ -94,7 +103,7 @@ export function MediaSessionSync({
safeSetActionHandler(session, "previoustrack", null);
safeSetActionHandler(session, "nexttrack", null);
};
}, [canSeek, isLive, onPreviousTrack, onNextTrack, remote]);
}, [canSeek, isLive, onPreviousTrack, onNextTrack, remote, sabrVideo]);

useEffect(() => {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/components/notification-toast-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "../hooks/use-auth";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { NOTIFICATIONS_UNREAD_KEY } from "../hooks/use-notifications";
import { useSettings } from "../hooks/use-settings";
import { fetchNotifications } from "../lib/api-notifications";
import {
advanceNotificationToastCursor,
createNotificationToastCursor,
findNewNotificationItems,
type NotificationToastCursor,
parseNotificationToastCursor,
visibleNotificationToastItems,
} from "../lib/notification-toast-cursor";
import { watchRouteSearch } from "../lib/watch-url";
import { useUiStore } from "../stores/ui-store";
Expand Down Expand Up @@ -42,7 +44,8 @@ export function NotificationToastHost() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { authReady, isAuthed, isGuest, me } = useAuth();
const { isBlocked, ready: blockedFilterReady } = useBlockedFilter();
const { isHidden, ready: visibilityFilterReady } = useBlockedFilter();
const { settings, settingsReady } = useSettings();
const openNotificationCenter = useUiStore((state) => state.openNotificationCenter);
const owner = me?.id ?? null;
const enabled = authReady && isAuthed && !isGuest && owner !== null;
Expand All @@ -66,7 +69,7 @@ export function NotificationToastHost() {
}, [owner]);

useEffect(() => {
if (!enabled || !owner || !query.data || !blockedFilterReady) return;
if (!enabled || !owner || !query.data || !visibilityFilterReady || !settingsReady) return;
queryClient.setQueryData(NOTIFICATIONS_UNREAD_KEY, {
unreadCount: query.data.unreadCount,
});
Expand All @@ -81,9 +84,23 @@ export function NotificationToastHost() {
const next = advanceNotificationToastCursor(current, query.data.items);
cursorRef.current = { owner, cursor: next };
writeCursor(owner, next);
const visibleItems = newItems.filter((item) => !isBlocked(item.video));
const visibleItems = visibleNotificationToastItems(
newItems,
settings.notificationPopupsEnabled,
isHidden,
);
if (!settings.notificationPopupsEnabled) setItems([]);
if (visibleItems.length > 0) setItems(visibleItems);
}, [blockedFilterReady, enabled, isBlocked, owner, query.data, queryClient]);
}, [
enabled,
isHidden,
owner,
query.data,
queryClient,
settings.notificationPopupsEnabled,
settingsReady,
visibilityFilterReady,
]);

useEffect(() => {
if (items.length === 0 || paused) return;
Expand Down
57 changes: 31 additions & 26 deletions apps/web/src/components/sabr-mse-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch";
import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch";
import { recordClientEvent } from "../lib/client-debug-log";
import { toAbsoluteApiUrl } from "../lib/env";
import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay";
import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference";
import { isAbortError } from "../lib/sabr-playback-retry";
import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek";
import { registerSabrVidstackControls } from "../lib/sabr-vidstack-bridge";
import { useAuthStore } from "../stores/auth-store";
import type { SabrMsePlayerProps } from "./sabr-mse-player-types";

export function SabrMsePlayer({
config,
playbackRatePreference,
Expand All @@ -34,8 +34,6 @@ export function SabrMsePlayer({
const engineRef = useRef<TypeTypeMsePlayer | null>(null);
const qualityRef = useRef<TypeTypeMseQuality | null>(null);
const pendingPlayRef = useRef(false);
const autoplayStartedRef = useRef(false);
const autoplayConfirmedRef = useRef(false);
const seekingRef = useRef(false);
const errorReportedRef = useRef(false);
const attachedVideoRef = useRef(false);
Expand Down Expand Up @@ -84,6 +82,7 @@ export function SabrMsePlayer({
errorReportedRef.current = false;
const replacingVideo = attachedVideoRef.current;
attachedVideoRef.current = true;
const autoplayAttempt = new SabrAutoplayAttempt();
const initialConfig = latestConfig();
const engine = new TypeTypeMsePlayer(video, {
endpoint: toAbsoluteApiUrl(""),
Expand Down Expand Up @@ -118,44 +117,49 @@ export function SabrMsePlayer({
playbackRate.apply(video, false);
playbackRateSettled = true;
};
const playEngine = () =>
engine.play().then(() => {
settlePlaybackRate();
});
const playEngine = () => engine.play().then(settlePlaybackRate);
playbackRate.initialize(video);
video.addEventListener("volumechange", volumeChange);
video.addEventListener("ratechange", playbackRateChange);
let autoplayStartTime = 0;
let engineLoaded = false;
const autoplayDeadline = new SabrAutoplayDeadline(() => {
if (!autoplayAttempt.expire()) return;
pendingPlayRef.current = false;
video.autoplay = false;
engine.pause();
});
const unguardAutoplay = guardAutoplay(video, autoplayAttempt, () => engine.pause());
const startAutoplay = () => {
if (!engineLoaded || autoplayConfirmedRef.current || video.readyState < 3) return;
if (autoplayStartedRef.current) {
if (!video.paused && video.currentTime >= autoplayStartTime + 0.25) {
autoplayConfirmedRef.current = true;
} else if (video.paused) {
autoplayStartedRef.current = false;
}
return;
}
if (!engineLoaded || video.readyState < 3) return;
if (!latestHandlers().autoplay && !pendingPlayRef.current) return;
autoplayStartTime = video.currentTime;
autoplayStartedRef.current = true;
void playEngine().catch(() => {
autoplayStartedRef.current = false;
});
if (!autoplayAttempt.begin()) return;
autoplayDeadline.arm();
void playEngine()
.then(() => {
autoplayDeadline.clear();
if (!autoplayAttempt.resolve()) engine.pause();
})
.catch((error: unknown) => {
autoplayDeadline.clear();
if (!autoplayAttempt.reject(error)) {
pendingPlayRef.current = false;
video.autoplay = false;
}
});
};
video.addEventListener("canplay", startAutoplay);
const autoplayTimer = window.setInterval(startAutoplay, 250);
const unregisterControls = registerSabrVidstackControls(video, {
play: () => {
pendingPlayRef.current = true;
video.autoplay = true;
if (!engineLoaded) return Promise.resolve();
return playEngine();
},
pause: (userInitiated = false) => {
if (!userInitiated && pendingPlayRef.current && !autoplayConfirmedRef.current) return;
if (!userInitiated && pendingPlayRef.current && !autoplayAttempt.isConfirmed) return;
pendingPlayRef.current = false;
autoplayConfirmedRef.current = true;
autoplayAttempt.resolve();
video.autoplay = false;
return engine.pause();
},
Expand Down Expand Up @@ -184,17 +188,18 @@ export function SabrMsePlayer({
latestHandlers().onPositionReaderChange(() => positionMs(video));
return () => {
offError();
unguardAutoplay();
unregisterControls();
video.removeEventListener("volumechange", volumeChange);
video.removeEventListener("ratechange", playbackRateChange);
video.removeEventListener("canplay", startAutoplay);
window.clearInterval(autoplayTimer);
autoplayDeadline.clear();
engine.destroy();
engineRef.current = null;
setEngineReady(false);
pendingPlayRef.current = false;
autoplayStartedRef.current = false;
autoplayConfirmedRef.current = false;
autoplayAttempt.reset();
cancelPendingSabrSeek(seekingRef);
seekingRef.current = false;
latestHandlers().onSeekStateChange(false);
Expand Down
Loading