Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
aa6ea92
fix: reject empty localization messages
Priveetee Aug 30, 2026
4d6aeaf
ci: route workflows to R730 runners
Priveetee Aug 31, 2026
f72003d
chore: benchmark dev runners
Priveetee Aug 31, 2026
ce6e563
ci: route localization checks to R730
Priveetee Aug 31, 2026
5a94ed6
fix: stabilize remote YouTube input handling
Priveetee Sep 3, 2026
f21d656
feat: add remote login bug reporting
Priveetee Sep 3, 2026
0541f47
fix: consume mse stalled playback recovery
Priveetee Sep 3, 2026
c1e40fc
fix: handle volume wheel on every player layout
Priveetee Sep 3, 2026
833a6ef
fix: localize volume slider labels
Priveetee Sep 3, 2026
ed48437
fix: capture volume wheel on native controls
Priveetee Sep 3, 2026
9f3c2e3
fix: prevent page scroll on volume wheel
Priveetee Sep 3, 2026
5b54088
fix: capture volume wheel on native slider
Priveetee Sep 3, 2026
b6ba355
test: cover YouTube short share URLs
Priveetee Sep 3, 2026
3d8e21c
fix: normalize YouTube short share links
Priveetee Sep 3, 2026
6d5db72
feat: show playback progress on video cards
Priveetee Sep 4, 2026
4b60155
feat: add provider source link sharing
Priveetee Sep 4, 2026
475b43d
feat: anchor share links dropdown
Priveetee Sep 4, 2026
b360956
style: simplify share dropdown
Priveetee Sep 4, 2026
9cba24c
style: use icon-only share menu
Priveetee Sep 4, 2026
3233a74
style: use compact share chips
Priveetee Sep 4, 2026
cedaef1
fix: show share destination names
Priveetee Sep 4, 2026
1f8a168
fix: align share dropdown with trigger
Priveetee Sep 4, 2026
158da3d
feat: keep watch player visible on scroll
Priveetee Sep 4, 2026
b634c13
fix: keep sticky player with related videos
Priveetee Sep 4, 2026
213ecc4
fix: disable sticky player in landscape
Priveetee Sep 4, 2026
3b004e5
fix: make sticky watch player compact
Priveetee Sep 4, 2026
6aa4c48
feat: persist watch player state across navigation
Priveetee Sep 4, 2026
c11db39
feat: add draggable floating watch player
Priveetee Sep 4, 2026
28a06f7
refactor: mount watch player outside route layout
Priveetee Sep 4, 2026
421a295
fix: preserve player anchor on initial mount
Priveetee Sep 4, 2026
3f18978
fix: simplify compact player controls and scrolling
Priveetee Sep 5, 2026
0e8017b
fix: synchronize compact playback controls after seeking
Priveetee Sep 5, 2026
94455a0
fix: update MSE engine for paused Firefox seeks
Priveetee Sep 5, 2026
97de733
fix: use WebKit seek isolation in frontend
Priveetee Sep 5, 2026
6d984d0
fix: preserve SABR position across WebKit provider replacement
Priveetee Sep 5, 2026
81b0d12
fix: capture WebKit positions before provider replacement
Priveetee Sep 5, 2026
85c26a3
refactor: isolate WebKit handoff capture
Priveetee Sep 5, 2026
51ae07f
style: soften compact player controls
Priveetee Sep 6, 2026
2d5cf23
chore: add ZIP64 archive reader for Takeout imports
Priveetee Sep 6, 2026
df71d8e
fix: prepare large Takeout archives before upload
Priveetee Sep 6, 2026
896ca9b
chore(l10n): update French translation
typetype-translations Aug 29, 2026
a3ecab8
chore(l10n): add German translation
Toastienergy Aug 30, 2026
88143ba
chore(l10n): update German translation
Toastienergy Aug 30, 2026
e783db5
chore(l10n): update German translation
Toastienergy Aug 31, 2026
a172c3b
chore(l10n): update German translation
Toastienergy Sep 1, 2026
4aa7897
chore(l10n): update German translation
Toastienergy Sep 2, 2026
c644d35
chore(l10n): update German translation
Toastienergy Sep 3, 2026
1ecc63c
chore(l10n): update German translation
Toastienergy Sep 4, 2026
82da9a1
chore(l10n): update German translation
Toastienergy Sep 5, 2026
56c3200
chore(l10n): update German translation
Toastienergy Sep 6, 2026
60aa81a
chore(l10n): update German translation
typetype-translations Sep 6, 2026
a00b827
Merge pull request #24 from typetype-translations/weblate-typetype-web
Priveetee Sep 6, 2026
7d9d88a
fix: expose German interface locale
Priveetee Sep 6, 2026
ec49d4f
fix: adapt interface layout to translated labels
Priveetee Sep 6, 2026
6a06c08
fix: keep related video cards within mobile viewport
Priveetee Sep 6, 2026
3a4e899
fix: make video settings locale responsive
Priveetee Sep 6, 2026
f4d88e4
fix: remove duplicate watch settings context
Priveetee Sep 6, 2026
2d464d4
chore: prepare frontend 1.8.0
Priveetee Sep 7, 2026
c86295a
chore: sync release with main
Priveetee Sep 7, 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: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ env:

jobs:
quality:
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }}
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }}
steps:
- uses: actions/checkout@v7.0.1

Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:

build:
needs: quality
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }}
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }}
steps:
- uses: actions/checkout@v7.0.1

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ env:

jobs:
coverage:
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }}
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }}
steps:
- uses: actions/checkout@v7.0.1

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

prepare:
needs: verify
runs-on: [self-hosted, Linux, X64, arko, typetype]
runs-on: [self-hosted, Linux, X64, r730, typetype]
permissions:
contents: read
outputs:
Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:

build-platform:
needs: prepare
runs-on: [self-hosted, Linux, X64, arko, typetype]
runs-on: [self-hosted, Linux, X64, r730, typetype]
timeout-minutes: 20
permissions:
contents: read
Expand Down Expand Up @@ -138,7 +138,7 @@ jobs:

publish:
needs: [prepare, build-platform]
runs-on: [self-hosted, Linux, X64, arko, typetype]
runs-on: [self-hosted, Linux, X64, r730, typetype]
timeout-minutes: 10
permissions:
contents: read
Expand Down Expand Up @@ -194,7 +194,7 @@ jobs:

notify-orchestrator:
needs: [prepare, publish]
runs-on: [self-hosted, Linux, X64, arko, typetype]
runs-on: [self-hosted, Linux, X64, r730, typetype]
permissions:
contents: read
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/localization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ env:

jobs:
report:
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }}
steps:
- uses: actions/checkout@v7.0.1

Expand Down
1,300 changes: 1,300 additions & 0 deletions apps/web/messages/de.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@
"watch_saved_later": "Saved to Watch later",
"watch_saving": "Saving...",
"watch_share": "Share",
"watch_share_source_link": "Share {provider} link",
"watch_share_typetype_link": "Share TypeType link",
"watch_show_less": "Show less",
"watch_show_more": "Show more",
"watch_show_replies": "Show replies",
Expand Down Expand Up @@ -266,6 +268,9 @@
"portability_import_completed": "Import completed",
"portability_items": "items",
"portability_no_import_format": "No import format is available.",
"portability_preparing_upload": "Preparing and uploading account data...",
"portability_takeout_metadata_limit": "The account data in this archive exceeds the import limits or is encrypted. Export YouTube separately with smaller ZIP parts.",
"portability_takeout_no_metadata": "No YouTube account data was found. Select an original Takeout ZIP containing your history, subscriptions or playlists, rather than a video-only part or a ZIP containing other ZIPs.",
"portability_import_title": "Import data",
"portability_import_description": "Preview and choose what to restore.",
"portability_import_from": "Import from",
Expand Down Expand Up @@ -506,6 +511,7 @@
"ui_click_deselect_all_then_select_only_youtube_and_my_activity": "Click \"Deselect all\", then select only YouTube and My Activity",
"ui_close_download": "Close download",
"ui_close_filters": "Close filters",
"ui_close_player": "Close player",
"ui_close_picker": "Close picker",
"ui_collection": "Collection",
"ui_community_only": "Community only",
Expand Down Expand Up @@ -619,6 +625,7 @@
"ui_members_only": "Members only",
"ui_minimum_segment_duration": "Minimum segment duration",
"ui_move_data_between_supported_apps": "Move data between supported apps",
"ui_move_player": "Move player",
"ui_move_down": "Move down",
"ui_move_up": "Move up",
"ui_name": "Name",
Expand Down
9 changes: 8 additions & 1 deletion apps/web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"watch_link_copied": "Lien copié dans le presse-papiers",
"watch_load_more_replies": "Charger plus de réponses",
"watch_more": "Plus",
"watch_pinned": "Épinglé",
"watch_pinned": "Pin",
"watch_removed_favorites": "Retiré des favoris",
"watch_removed_later": "Retiré de À regarder plus tard",
"watch_remove_later": "Retirer de À regarder plus tard",
Expand All @@ -99,6 +99,8 @@
"watch_saved_later": "Ajouté à À regarder plus tard",
"watch_saving": "Enregistrement...",
"watch_share": "Partager",
"watch_share_source_link": "Partager le lien {provider}",
"watch_share_typetype_link": "Partager le lien TypeType",
"watch_show_less": "Afficher moins",
"watch_show_more": "Afficher plus",
"watch_show_replies": "Afficher les réponses",
Expand Down Expand Up @@ -266,6 +268,9 @@
"portability_import_completed": "Import terminé",
"portability_items": "éléments",
"portability_no_import_format": "Aucun format d'import n'est disponible.",
"portability_preparing_upload": "Préparation et envoi des données du compte...",
"portability_takeout_metadata_limit": "Les données de compte de cette archive dépassent les limites d'import ou sont chiffrées. Exportez YouTube séparément avec des parties ZIP plus petites.",
"portability_takeout_no_metadata": "Aucune donnée de compte YouTube trouvée. Sélectionnez un ZIP Takeout original contenant votre historique, vos abonnements ou vos playlists, plutôt qu'une partie contenant uniquement des vidéos ou un ZIP contenant d'autres ZIP.",
"portability_import_title": "Importer des données",
"portability_import_description": "Prévisualisez les données et choisissez ce qui doit être restauré.",
"portability_import_from": "Importer depuis",
Expand Down Expand Up @@ -506,6 +511,7 @@
"ui_click_deselect_all_then_select_only_youtube_and_my_activity": "Click \"Deselect all\", then select only YouTube and My Activity",
"ui_close_download": "Fermer download",
"ui_close_filters": "Fermer filters",
"ui_close_player": "Fermer le lecteur",
"ui_close_picker": "Fermer picker",
"ui_collection": "Collection",
"ui_community_only": "Community only",
Expand Down Expand Up @@ -619,6 +625,7 @@
"ui_members_only": "Members only",
"ui_minimum_segment_duration": "Minimum segment duration",
"ui_move_data_between_supported_apps": "Move data between supported apps",
"ui_move_player": "Déplacer le lecteur",
"ui_move_down": "Move down",
"ui_move_up": "Move up",
"ui_name": "Nom",
Expand Down
5 changes: 3 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.7.1",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -14,8 +14,9 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@typetype/mse": "0.1.57",
"@typetype/mse": "0.1.59",
"@vidstack/react": "1.12.13",
"@zip.js/zip.js": "^2.11.2",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
"lucide-react": "^1.24.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/project.inlang/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "fr"],
"locales": ["en", "fr", "de"],
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json",
Expand Down
28 changes: 28 additions & 0 deletions apps/web/public/THIRD_PARTY_NOTICES.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
TypeType Frontend includes software distributed under third-party licenses.

zip.js
https://github.com/gildas-lormeau/zip.js
BSD 3-Clause License
Copyright (c) 2023, Gildas Lormeau

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Paraglide JS
Copyright (c) 2025 Opral US Inc.
https://github.com/opral/paraglide-js
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/audio-play-button.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { useInterfaceLocale } from "../hooks/use-interface-locale";
import { useVideoPaused } from "../hooks/use-video-paused";
import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge";
import { useMediaRemote, useMediaState } from "../lib/vidstack";
import { m } from "../paraglide/messages.js";
import { AudioPauseIcon, AudioPlayIcon } from "./audio-control-icons";

export function AudioPlayButton({ video = null }: { video?: HTMLVideoElement | null }) {
export function AudioPlayButton({
video = null,
disabled = false,
}: {
video?: HTMLVideoElement | null;
disabled?: boolean;
}) {
const { locale } = useInterfaceLocale();
const remote = useMediaRemote();
const paused = useMediaState("paused");
const mediaPaused = useMediaState("paused");
const paused = useVideoPaused(video, mediaPaused);
const Icon = paused ? AudioPlayIcon : AudioPauseIcon;
const label = paused ? m.player_play({}, { locale }) : m.player_pause({}, { locale });

const togglePlayback = async () => {
if (video) return requestSabrVidstackPlayback(video, paused, true);
if (video) return requestSabrVidstackPlayback(video, video.paused, true);
if (paused) await remote.play();
else await remote.pause();
};
Expand All @@ -23,6 +31,7 @@ export function AudioPlayButton({ video = null }: { video?: HTMLVideoElement | n
className="typetype-audio-play-button"
aria-label={label}
title={label}
disabled={disabled}
onClickCapture={(event) => {
event.stopPropagation();
void togglePlayback().catch(() => {});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/audio-seek-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function AudioSeekButton({ direction, disabled = false, video = null }: P
const Icon = direction === "backward" ? AudioSeekBackward10Icon : AudioSeekForward10Icon;

const seek = () => {
const target = currentTime + seconds;
const target = (video?.currentTime ?? currentTime) + seconds;
const bounded = Math.min(Math.max(target, 0), duration > 0 ? duration : target);
if (video && requestSabrSeek(video, bounded)) return;
remote.seek(bounded);
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/channel-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ export function ChannelFilterBar({
<section className="border-y border-border py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{searchAvailable && (
<div className="flex w-fit items-center gap-2 text-sm">
<div className="flex max-w-full flex-wrap items-center gap-2 text-sm">
{channelTabs().map((item) => (
<button
key={item.tab}
type="button"
onClick={() => onTabChange(item.tab)}
className={`border-border-strong border-b py-1 font-medium transition-colors ${
className={`typetype-adaptive-control inline-flex max-w-full border-border-strong border-b py-1 font-medium transition-colors ${
tab === item.tab
? "border-fg text-fg"
: "border-transparent text-fg-soft hover:text-fg"
Expand Down Expand Up @@ -104,15 +104,15 @@ export function ChannelFilterBar({
</div>
)}
{!isSearching && tab === "videos" && (
<div className="flex w-fit items-center gap-2 text-sm">
<div className="flex max-w-full flex-wrap items-center gap-2 text-sm">
{CHANNEL_SORT_OPTIONS.map((option) => {
const selected = option.value === sort;
return (
<button
key={option.value}
type="button"
onClick={() => onSortChange(channelSortOrDefault(option.value))}
className={`border-border-strong border-b py-1 font-medium transition-colors ${
className={`typetype-adaptive-control inline-flex max-w-full border-border-strong border-b py-1 font-medium transition-colors ${
selected ? "border-fg text-fg" : "border-transparent text-fg-soft hover:text-fg"
}`}
>
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/channel-page-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { useMemo } from "react";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useChannel } from "../hooks/use-channel";
import { useDocumentTitle } from "../hooks/use-document-title";
import { useVideoProgressMap } from "../hooks/use-progress";
import { useSubscriptions } from "../hooks/use-subscriptions";
import { isChannelNotAllowedError } from "../lib/allow-list-error";
import type { ChannelSort } from "../lib/api-discovery";
import type { ChannelTab } from "../lib/channel-route-url";
import { detectProvider } from "../lib/provider";
import { videoProgressUrl } from "../lib/video-progress";
import { m } from "../paraglide/messages.js";
import { ChannelFilterBar } from "./channel-filter-bar";
import { ChannelPageHeader } from "./channel-page-header";
Expand Down Expand Up @@ -47,6 +49,7 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga
const subscribed = isSubscribed(sourceUrl);
const searchAvailable = detectProvider(sourceUrl) === "youtube";
const visibleVideos = useMemo(() => filter(videos), [filter, videos]);
const progressByUrl = useVideoProgressMap(visibleVideos);
const isInitialLoading = isLoading && !meta;
const isReplacingVideos = isFetching && !isFetchingNextPage && visibleVideos.length === 0;

Expand Down Expand Up @@ -144,7 +147,10 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga
className="animate-card-pop-in"
style={{ animationDelay: `${Math.min(index * 45, 270)}ms` }}
>
<VideoCard stream={v} />
<VideoCard
stream={v}
progressMs={progressByUrl.get(videoProgressUrl(v))?.position}
/>
</div>
))}
</div>
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/compact-player-controls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Volume2, VolumeX } from "lucide-react";
import { useInterfaceLocale } from "../hooks/use-interface-locale";
import { MuteButton, useMediaState } from "../lib/vidstack";
import { m } from "../paraglide/messages.js";
import { AudioPlayButton } from "./audio-play-button";
import { AudioSeekButton } from "./audio-seek-button";

export function CompactPlayerControls({
video,
seeking,
}: {
video: HTMLVideoElement | null;
seeking: boolean;
}) {
const { locale } = useInterfaceLocale();
const muted = useMediaState("muted");
const volume = useMediaState("volume");
const canSeek = useMediaState("canSeek");
const silent = muted || volume === 0;
const Icon = silent ? VolumeX : Volume2;
const label = silent ? m.player_unmute({}, { locale }) : m.player_mute({}, { locale });
return (
<div className="typetype-compact-controls">
<AudioSeekButton direction="backward" video={video} disabled={seeking || !canSeek} />
<AudioPlayButton video={video} disabled={seeking} />
<AudioSeekButton direction="forward" video={video} disabled={seeking || !canSeek} />
<MuteButton aria-label={label} title={label} disabled={seeking}>
<Icon size={20} aria-hidden="true" />
</MuteButton>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/history-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function HistoryCard({ item, onRemove }: HistoryCardProps) {
<img
src={branding.thumbnail}
alt={branding.title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
className={`h-full w-full object-cover transition-transform duration-200 group-hover:scale-105 ${watched ? "grayscale opacity-60" : ""}`}
loading="lazy"
decoding="async"
/>
Expand Down
Loading