diff --git a/MODS.md b/MODS.md new file mode 100644 index 00000000..fda920eb --- /dev/null +++ b/MODS.md @@ -0,0 +1,49 @@ +# Fluent Reader Mods + +Fork local basado en `FluentFlame/fluentflame-reader` (`2.2.0-dev.3`). + +Rama: `mods/local-ui`. + +## Cambios aplicados + +### 1. Fechas futuras +- `src/scripts/models/item.ts`: si un feed publica un ítem con fecha futura, se usa la fecha de descarga (`fetchedDate`) en lugar de dejarlo clavado arriba para siempre. +- `src/components/utils/time.tsx`: una fecha futura nunca se muestra como “now”; se muestra su fecha real. +- `src/scripts/db.ts` (versión 11): migración que corrige los ítems con fecha futura que ya estaban guardados. + +Esto corrige el caso de “Visual Studio Code 1.144 (Insiders)”: el feed trae `2026-10-28`, y la app lo mostraba como “now” y siempre primero. + +### 2. Deduplicación por enlace +- `src/scripts/db.ts`: nueva versión de Dexie (10) con índice `[source+link]`. +- `src/scripts/models/source.ts`: los ítems se deduplican por `source + link` cuando existe enlace; si no, se mantiene el criterio anterior (fecha + fuente + título). + +Esto evita que feeds sin `pubDate` (o con fecha futura) inserten copias en cada refresco. + +### 3. Concurrencia de refresco +- `src/scripts/models/item.ts`: máximo **24 feeds simultáneos** al refrescar, en lugar de lanzar todos a la vez. + +Evita saturar la red y que un feed lento bloquee el refresco completo. + +### 4. Diseño de tarjetas +- `resources/styles/mods.css` (nuevo) e importado al final de `resources/index.css`. +- Incluye el rediseño de tarjetas: 264×280, radio 14px, portada 16:9, degradado, hover, snippet visible, estados leídos y tarjetas sin portada. + +### 5. YouTube +- `src/scripts/youtube.ts`: extracción de IDs y URL de miniatura/embed. +- `src/scripts/models/item.ts`: si el feed no trae miniatura y el enlace es de YouTube, se usa `i.ytimg.com`. +- `src/components/cards/default-card.tsx`: clase `yt-card` y badge ▶. +- `src/components/article.tsx`: para YouTube carga el reproductor iframe en lugar de la página completa. +- `src/main/youtube-server.ts`: servidor local en `127.0.0.1:39217` que sirve el iframe (YouTube rechaza el embed desde `file://` con error 153). +- `src/electron.ts`: arranca el servidor y fija cookies de consentimiento de YouTube. + +## Pendiente / siguientes mods +- Migración opcional para limpiar duplicados antiguos ya existentes. +- Revisar el resto de feeds sin fecha para no depender de aislamientos manuales. + +## Build +```bash +npm ci +npm run build +``` + +La instalación original en `C:\Program Files\Fluentflame Reader` no se toca. diff --git a/resources/index.css b/resources/index.css index aa0d0405..1ae2023f 100644 --- a/resources/index.css +++ b/resources/index.css @@ -4,3 +4,4 @@ @import "styles/feeds.css"; @import "styles/cards.css"; @import "styles/dark.css"; +@import "styles/mods.css"; diff --git a/resources/styles/mods.css b/resources/styles/mods.css new file mode 100644 index 00000000..8f53b661 --- /dev/null +++ b/resources/styles/mods.css @@ -0,0 +1,220 @@ +/* Fluentflame Reader — card view refresh (preview) */ +.default-card { + --card-radius: 14px; + box-sizing: border-box; + width: 264px; + height: 280px; + margin: 12px 8px; + border-radius: var(--card-radius); + background: var(--white); + border: 1px solid color-mix(in srgb, var(--neutralDarker) 10%, transparent); + box-shadow: 0 1px 2px rgba(0, 0, 0, .06), 0 10px 28px rgba(0, 0, 0, .10); + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} +.default-card:hover, +.ms-Fabric--isFocusVisible .default-card:focus { + transform: translateY(-3px); + border-color: color-mix(in srgb, var(--neutralDarker) 18%, transparent); + box-shadow: 0 2px 4px rgba(0, 0, 0, .08), 0 18px 44px rgba(0, 0, 0, .16); +} +.default-card:active { + transform: translateY(-1px) scale(.995); + box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 8px 20px rgba(0, 0, 0, .12); +} + +.default-card img.head, +.default-card canvas.head, +.default-card video.head { + height: 148px; + transition: transform .35s cubic-bezier(.2, .7, .2, 1); +} +.default-card:hover img.head, +.default-card:hover canvas.head, +.default-card:hover video.head { + transform: scale(1.045); +} +.default-card:has(.head)::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 148px; + background: linear-gradient(to bottom, transparent 55%, rgba(0, 0, 0, .30)); + pointer-events: none; + z-index: 1; +} +.default-card.transform:hover img.head, +.default-card.transform:hover canvas.head, +.default-card.transform:hover video.head, +.ms-Fabric--isFocusVisible .default-card.transform:focus img.head, +.ms-Fabric--isFocusVisible .default-card.transform:focus canvas.head, +.ms-Fabric--isFocusVisible .default-card.transform:focus video.head { + transform: scale(1.045); +} +.default-card.transform:hover p, +.default-card.transform:hover h3, +.ms-Fabric--isFocusVisible .default-card.transform:focus p, +.ms-Fabric--isFocusVisible .default-card.transform:focus h3 { + transform: none; +} + +.default-card .info { + margin: 8px 14px 0; + line-height: 16px; + align-items: center; +} +.default-card .info img { + width: 16px; + height: 16px; + margin-right: 7px; + border-radius: 4px; + object-fit: cover; +} +.default-card .info span.name { + font-size: 12px; + font-weight: 600; + letter-spacing: .01em; + color: var(--neutralSecondary); +} +.default-card .info span.time { + font-size: 11px; + color: var(--neutralTertiaryAlt); + white-space: nowrap; +} +.default-card .read-indicator::after { + width: 7px; + height: 7px; + margin: 4.5px 5px; + border-radius: 50%; + background: #ffaa44; + box-shadow: 0 0 0 3px rgba(255, 170, 68, .16); +} + +.default-card h3.title { + margin: 6px 14px 0; + font-size: 16px; + line-height: 22px; + font-weight: 700; + letter-spacing: -.01em; + -webkit-line-clamp: 2; +} +.default-card.read h3.title { font-weight: 600; } + +.default-card p.snippet { + margin: 4px 14px 0; + font-size: 13px; + line-height: 18px; + color: var(--neutralSecondary); + -webkit-line-clamp: 2; + transform: none; + opacity: 1; +} +.default-card p.snippet.show { transform: none; } + +.default-card.read { color: var(--neutralSecondary); } +.default-card.read img.head, +.default-card.read canvas.head, +.default-card.read video.head { + opacity: .72; + filter: saturate(.75); +} + +.default-card:not(:has(.head)) { + display: flex; + flex-direction: column; + justify-content: center; + padding: 18px 0; + background: + radial-gradient(130% 110% at 0% 0%, color-mix(in srgb, var(--primary) 22%, transparent), transparent 62%), + radial-gradient(100% 90% at 100% 100%, color-mix(in srgb, #8764b8 14%, transparent), transparent 58%), + var(--white); +} +.default-card:not(:has(.head)) .bg { display: none; } +.default-card:not(:has(.head))::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 35%, #8764b8)); + pointer-events: none; + z-index: 2; +} +.default-card:not(:has(.head)) .info img { + width: 26px; + height: 26px; + border-radius: 7px; + margin-right: 10px; +} +.default-card:not(:has(.head)) .info { margin-top: 0; } +.default-card:not(:has(.head)) h3.title { -webkit-line-clamp: 4; font-size: 17px; line-height: 23px; } +.default-card:not(:has(.head)) p.snippet { -webkit-line-clamp: 3; } + +.ms-Fabric--isFocusVisible .card:focus::after { + border-radius: 10px; + top: 3px; + left: 3px; + width: calc(100% - 8px); + height: calc(100% - 8px); +} + +/* YouTube cards: real thumbnail + play badge */ +.default-card.ff-yt { background: var(--white); } +.ff-yt-head { + position: relative; + height: 148px; + overflow: hidden; + background: #000; +} +.ff-yt-head img.head { + display: block; + width: 100%; + height: 148px; + object-fit: cover; +} +.ff-yt-play { + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(0, 0, 0, .62); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 17px; + line-height: 1; + padding-left: 3px; + box-sizing: border-box; + pointer-events: none; + backdrop-filter: blur(4px); + box-shadow: 0 2px 10px rgba(0, 0, 0, .35); +} +.default-card.ff-yt .info { margin-top: 8px; } +.default-card.ff-yt.ff-yt-no-snippet h3.title { -webkit-line-clamp: 3; } + +/* YouTube card play badge */ +.default-card .yt-play { + position: absolute; + z-index: 3; + top: 74px; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(0, 0, 0, .62); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 17px; + line-height: 1; + padding-left: 3px; + box-sizing: border-box; + pointer-events: none; + backdrop-filter: blur(4px); + box-shadow: 0 2px 10px rgba(0, 0, 0, .35); +} diff --git a/src/components/article.tsx b/src/components/article.tsx index 9cd15169..49175916 100644 --- a/src/components/article.tsx +++ b/src/components/article.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import intl from "react-intl-universal"; import { renderToString } from "react-dom/server"; import { RSSItem } from "../scripts/models/item"; +import { getYouTubeId, youtubeEmbedPath } from "../scripts/youtube"; import { Stack, CommandBarButton, @@ -500,6 +501,12 @@ class Article extends React.Component { this.toggleWebpage, this.moreMenuProps, ); + const youtubeId = getYouTubeId(this.props.item.link); + const articleSrc = youtubeId + ? youtubeEmbedPath(youtubeId) + : this.state.loadWebpage + ? this.props.item.link + : this.articleView(); return ( {commandBar} @@ -512,15 +519,15 @@ class Article extends React.Component { (this.state.loadWebpage ? "_" : "") + (this.state.loadFull ? "__" : "") } - src={ - this.state.loadWebpage - ? this.props.item.link - : this.articleView() - } + src={articleSrc} allowpopups={"true" as unknown as boolean} webpreferences="contextIsolation,disableDialogs,autoplayPolicy=document-user-activation-required" partition={ - this.state.loadWebpage ? "sandbox" : undefined + youtubeId + ? undefined + : this.state.loadWebpage + ? "sandbox" + : undefined } /> )} diff --git a/src/components/cards/default-card.tsx b/src/components/cards/default-card.tsx index 88f409c2..ad12f526 100644 --- a/src/components/cards/default-card.tsx +++ b/src/components/cards/default-card.tsx @@ -5,6 +5,7 @@ import Highlights from "./highlights"; import { SourceTextDirection } from "../../scripts/models/source"; import { RSSItem } from "../../scripts/models/item"; import CardThumbnail from "./thumbnail"; +import { getYouTubeId } from "../../scripts/youtube"; const className = (props: Card.Props) => { let cn = ["card", "default-card"]; @@ -18,6 +19,7 @@ const className = (props: Card.Props) => { cn.push("transform"); if (props.item.hidden) cn.push("hidden"); if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl"); + if (getYouTubeId(props.item.link)) cn.push("yt-card"); return cn.join(" "); }; @@ -29,6 +31,7 @@ function DefaultCard(props: Card.Props): React.JSX.Element { const title = RSSItem.getTitle(props.item); const hasThumbnail = props.item.thumbnails?.length > 0 || props.item.thumb != null; + const youtubeId = getYouTubeId(props.item.link); return (
+ {youtubeId && ▶}

diff --git a/src/components/utils/time.tsx b/src/components/utils/time.tsx index d9691e92..e7a686f2 100644 --- a/src/components/utils/time.tsx +++ b/src/components/utils/time.tsx @@ -22,9 +22,11 @@ class Time extends React.Component { } displayTime(past: Date, now: Date): string { - // difference in seconds + // difference in minutes let diff = (now.getTime() - past.getTime()) / 60000; - if (diff < 1) return intl.get("time.now"); + // Never render a future date as "now"; show its actual date instead. + if (diff < 0) return past.toLocaleDateString(); + else if (diff < 1) return intl.get("time.now"); else if (diff < 60) return Math.floor(diff) + intl.get("time.m"); else if (diff < 1440) return Math.floor(diff / 60) + intl.get("time.h"); else return Math.floor(diff / 1440) + intl.get("time.d"); diff --git a/src/electron.ts b/src/electron.ts index 06abba76..05b6eafe 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -1,8 +1,9 @@ -import { app, ipcMain, Menu, nativeTheme } from "electron"; +import { app, ipcMain, Menu, nativeTheme, session } from "electron"; import { ThemeSettings, type SchemaTypes } from "./schema-types"; import { getStore } from "./main/settings"; import { performUpdate, maybeRenameOldConfig } from "./main/update-scripts"; import { WindowManager } from "./main/window"; +import { startYouTubeServer } from "./main/youtube-server"; import { type CustomArgs } from "./general-types"; import minimist from "minimist"; @@ -59,6 +60,32 @@ function init() { performUpdate(store); nativeTheme.themeSource = store.get("theme", ThemeSettings.Default); + startYouTubeServer(); + app.whenReady().then(async () => { + // Avoid YouTube's consent wall inside the embedded player. These + // cookies are harmless if the user already made a choice. + const expirationDate = Date.now() / 1000 + 31536000; + for (const [name, value] of [ + ["SOCS", "CAI"], + ["CONSENT", "YES+cb"], + ]) { + try { + await session.defaultSession.cookies.set({ + url: "https://www.youtube.com", + name, + value, + domain: ".youtube.com", + path: "/", + secure: true, + sameSite: "no_restriction", + expirationDate, + }); + } catch (e) { + // Ignore cookie failures. + } + } + }); + const args = parseArgs(); CUSTOM_ARGS = { forceFrame: args["force-frame"] != null, diff --git a/src/main/youtube-server.ts b/src/main/youtube-server.ts new file mode 100644 index 00000000..cc84fd3b --- /dev/null +++ b/src/main/youtube-server.ts @@ -0,0 +1,61 @@ +import * as http from "http"; +import { YOUTUBE_EMBED_PORT } from "../scripts/youtube"; + +let started = false; + +function iframePage(id: string): string { + return ` + + + + + + + + + +`; +} + +/** + * Serve a tiny same-origin page so YouTube can render its player in an iframe. + * YouTube rejects the embed when the parent is file:// (error 153). + */ +export function startYouTubeServer() { + if (started) return; + started = true; + const server = http.createServer((req, res) => { + const url = new URL( + req.url || "/", + `http://127.0.0.1:${YOUTUBE_EMBED_PORT}`, + ); + if (url.pathname !== "/yt") { + res.writeHead(404); + res.end("not found"); + return; + } + const id = url.searchParams.get("v") || ""; + if (!/^[A-Za-z0-9_-]{11}$/.test(id)) { + res.writeHead(400); + res.end("bad video id"); + return; + } + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(iframePage(id)); + }); + server.on("error", (e: NodeJS.ErrnoException) => { + if (e.code !== "EADDRINUSE") { + console.error("YouTube iframe server:", e.message); + } + }); + server.listen(YOUTUBE_EMBED_PORT, "127.0.0.1"); +} diff --git a/src/scripts/db.ts b/src/scripts/db.ts index 11301e78..c0b10abe 100644 --- a/src/scripts/db.ts +++ b/src/scripts/db.ts @@ -93,6 +93,32 @@ fluentDB.version(9).stores({ items: `++iid, source, date, serviceRef`, }); +// Add a compound index so items can be deduplicated by source + link. +fluentDB.version(10).stores({ + integrity: `++index`, + sources: `++sid, &url`, + items: `++iid, source, date, serviceRef, [source+link]`, +}); + +// Repair items already stored with a future date (scheduled releases). +fluentDB + .version(11) + .stores({ + integrity: `++index`, + sources: `++sid, &url`, + items: `++iid, source, date, serviceRef, [source+link]`, + }) + .upgrade(async (tx) => { + const now = new Date(); + await tx.table("items").toCollection().modify((item) => { + const date = new Date(item.date); + if (!Number.isNaN(date.getTime()) && date.getTime() > now.getTime()) { + const fetched = new Date(item.fetchedDate); + item.date = Number.isNaN(fetched.getTime()) ? now : fetched; + } + }); + }); + export async function calculateItemSize(): Promise { await fluentDB.open(); let result = 0; diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 6ca4d2a8..437129f0 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -1,6 +1,8 @@ import { type ItemEntry, fluentDB } from "../db"; import intl from "react-intl-universal"; import { type MyParserItem, timeoutPromise } from "../utils"; +import { ThumbnailTypePref } from "../../schema-types"; +import { getYouTubeId, youtubeThumbnailUrl } from "../youtube"; import { type ThumbnailAttributes, generateThumbnailAttrList, @@ -55,9 +57,16 @@ export class RSSItem { this.title = item.title || null; this.link = item.link || ""; this.fetchedDate = new Date(); - this.date = new Date( + const publishedDate = new Date( item.isoDate ?? item.pubDate ?? item.updated ?? this.fetchedDate, ); + // Some feeds publish scheduled items with a future date. Keep them + // from being pinned to the top forever and displayed as "now". + this.date = + Number.isNaN(publishedDate.getTime()) || + publishedDate.getTime() > this.fetchedDate.getTime() + ? this.fetchedDate + : publishedDate; this.thumbnails = []; this.thumbnailJobs = []; this.creator = item.creator; @@ -127,6 +136,20 @@ export class RSSItem { // Ensure we don't spend more than 1 second on loading the thumbnails. const jobsComplete = Promise.allSettled(item.thumbnailJobs); await Promise.race([jobsComplete, timeoutPromise(1000)]); + // YouTube feeds usually don't expose media thumbnails. Use the + // predictable i.ytimg.com cover instead. + if (item.thumb == null && (!item.thumbnails || item.thumbnails.length === 0)) { + const youtubeId = getYouTubeId(item.link); + if (youtubeId) { + item.thumbnails = [ + { + medium: "image", + url: youtubeThumbnailUrl(youtubeId), + type: ThumbnailTypePref.Other, + }, + ]; + } + } } } @@ -344,20 +367,39 @@ export function fetchItems( : sids .map((sid) => sourcesState[sid]) .filter((s) => !s.serviceRef); - for (let source of sources) { - let promise = RSSSource.fetchItems(source); - promise.then(() => - dispatch( - updateSource({ - ...source, - lastFetched: new Date(), - }), - ), - ); - promise.finally(() => dispatch(fetchItemsIntermediate())); - promises.push(promise); - } - dispatch(fetchItemsRequest(promises.length)); + dispatch(fetchItemsRequest(sources.length)); + // Limit the number of simultaneous feed requests. Fetching all + // sources at once saturates the network and makes one slow feed + // hold up the whole refresh. + const maxConcurrent = 24; + let nextSource = 0; + const workers = Array.from( + { length: Math.min(maxConcurrent, sources.length) }, + async () => { + while (nextSource < sources.length) { + const source = sources[nextSource++]; + const promise = RSSSource.fetchItems(source); + promise.then(() => + dispatch( + updateSource({ + ...source, + lastFetched: new Date(), + }), + ), + ); + promise.finally(() => + dispatch(fetchItemsIntermediate()), + ); + promises.push(promise); + try { + await promise; + } catch (e) { + // Promise.allSettled below reports the failure. + } + } + }, + ); + await Promise.all(workers); const results = await Promise.allSettled(promises); return await new Promise((resolve, reject) => { let items = new Array(); diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index 81112d19..e119dda9 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -73,12 +73,23 @@ export class RSSSource { item: MyParserItem, ): Promise { let i = new RSSItem(item, source); - const items = await db.fluentDB.items - .where("date") - .equals(i.date) - .and((item) => item.source === i.source && item.title === i.title) - .limit(1) - .toArray(); + // Deduplicate by link when available. Feeds without a stable date + // (or with a future date) would otherwise be re-inserted every fetch. + const items = i.link + ? await db.fluentDB.items + .where("[source+link]") + .equals([i.source, i.link]) + .limit(1) + .toArray() + : await db.fluentDB.items + .where("date") + .equals(i.date) + .and( + (item) => + item.source === i.source && item.title === i.title, + ) + .limit(1) + .toArray(); if (items.length === 0) { await RSSItem.parseContent(i, item); if (source.rules) SourceRule.applyAll(source.rules, i); diff --git a/src/scripts/youtube.ts b/src/scripts/youtube.ts new file mode 100644 index 00000000..c399606e --- /dev/null +++ b/src/scripts/youtube.ts @@ -0,0 +1,42 @@ +export const YOUTUBE_EMBED_PORT = 39217; + +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/; + +function validId(value: string | null | undefined): string | null { + return value && YOUTUBE_ID.test(value) ? value : null; +} + +/** + * Extract a YouTube video id from the usual URL shapes: + * watch?v=, youtu.be/, /shorts/, /live/ and /embed/. + */ +export function getYouTubeId(link: string): string | null { + if (!link) return null; + try { + const url = new URL(link); + const host = url.hostname.replace(/^www\./, "").replace(/^m\./, ""); + if (host === "youtu.be") { + return validId(url.pathname.split("/")[1]); + } + if (host === "youtube.com" || host === "youtube-nocookie.com") { + if (url.pathname === "/watch") { + return validId(url.searchParams.get("v")); + } + const match = url.pathname.match( + /^\/(?:shorts|live|embed)\/([A-Za-z0-9_-]{11})/, + ); + return match ? match[1] : null; + } + } catch (e) { + return null; + } + return null; +} + +export function youtubeThumbnailUrl(id: string): string { + return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; +} + +export function youtubeEmbedPath(id: string): string { + return `http://127.0.0.1:${YOUTUBE_EMBED_PORT}/yt?v=${id}`; +}