Merge: promote dev to prod
This commit is contained in:
commit
729ba7489c
18 changed files with 407 additions and 25 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.22.2
|
0.22.3
|
||||||
27
backend/alembic/versions/0043_asset_source_url.py
Normal file
27
backend/alembic/versions/0043_asset_source_url.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""canonical source webpage URL on media assets
|
||||||
|
|
||||||
|
Revision ID: 0043_asset_source_url
|
||||||
|
Revises: 0042_download_links
|
||||||
|
Create Date: 2026-07-04
|
||||||
|
|
||||||
|
Stores yt-dlp's canonical `webpage_url` for a download's source, so the Downloads page can show a
|
||||||
|
clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on
|
||||||
|
extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0043_asset_source_url"
|
||||||
|
down_revision: Union[str, None] = "0042_download_links"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("media_assets", sa.Column("source_webpage_url", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("media_assets", "source_webpage_url")
|
||||||
|
|
@ -741,6 +741,10 @@ class MediaAsset(Base, TimestampMixin):
|
||||||
uploader: Mapped[str | None] = mapped_column(String(255))
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
upload_date: Mapped[date | None] = mapped_column(Date)
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
|
||||||
|
# reference shown on the Downloads page. Null until the worker extracts it (older/queued rows
|
||||||
|
# fall back to a URL derived from source_kind+source_ref).
|
||||||
|
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
||||||
error: Mapped[str | None] = mapped_column(Text)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,20 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||||
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
|
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_url(job: DownloadJob, asset: MediaAsset | None) -> str | None:
|
||||||
|
"""The clean "downloaded from" URL for the Downloads page: the canonical page URL yt-dlp
|
||||||
|
recorded, else one derived from source_kind+source_ref (covers queued/older rows before the
|
||||||
|
worker fills it). Returns None for edit derivatives — a clip's source is the user's own earlier
|
||||||
|
download, not a web page, so there's no meaningful external URL to show."""
|
||||||
|
if job.job_kind == "edit":
|
||||||
|
return None
|
||||||
|
if asset is not None and asset.source_webpage_url:
|
||||||
|
return asset.source_webpage_url
|
||||||
|
if job.source_kind in ("youtube", "url"):
|
||||||
|
return service.source_url(job.source_kind, job.source_ref)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
||||||
return {
|
return {
|
||||||
|
|
@ -82,6 +96,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
"created_at": job.created_at.isoformat() if job.created_at else None,
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||||
"source_kind": job.source_kind,
|
"source_kind": job.source_kind,
|
||||||
"source_ref": job.source_ref,
|
"source_ref": job.source_ref,
|
||||||
|
"source_url": _reference_url(job, asset),
|
||||||
"profile_id": job.profile_id,
|
"profile_id": job.profile_id,
|
||||||
"spec": job.profile_snapshot,
|
"spec": job.profile_snapshot,
|
||||||
"display_name": job.display_name,
|
"display_name": job.display_name,
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||||
asset.thumbnail_url = info.get("thumbnail")
|
asset.thumbnail_url = info.get("thumbnail")
|
||||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||||
|
asset.source_webpage_url = info.get("webpage_url")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -409,6 +410,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
||||||
asset.uploader = meta.uploader
|
asset.uploader = meta.uploader
|
||||||
asset.upload_date = meta.upload_date
|
asset.upload_date = meta.upload_date
|
||||||
asset.thumbnail_url = meta.thumbnail_url
|
asset.thumbnail_url = meta.thumbnail_url
|
||||||
|
asset.source_webpage_url = info.get("webpage_url")
|
||||||
asset.nfo_written = nfo_ok
|
asset.nfo_written = nfo_ok
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,23 @@ function loadInitialPage(): Page {
|
||||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||||
// post-login effect loads the real ones once the id arrives.
|
// post-login effect loads the real ones once the id arrives.
|
||||||
function loadAccountFilters(id: number | null): FeedFilters {
|
function loadAccountFilters(id: number | null): FeedFilters {
|
||||||
|
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||||
|
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||||
|
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||||
|
// very first load, after which that state persists like any other. (Re-apply the default view
|
||||||
|
// from the sidebar to return to it.)
|
||||||
|
const fKey = accountKey(LS.filters, id);
|
||||||
|
let hasStored = false;
|
||||||
|
try {
|
||||||
|
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
||||||
|
} catch {
|
||||||
|
/* localStorage may be unavailable */
|
||||||
|
}
|
||||||
|
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
|
||||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||||
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
|
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
|
||||||
if (def) return { ...DEFAULT_FILTERS, ...def };
|
if (def) return { ...DEFAULT_FILTERS, ...def };
|
||||||
const fKey = accountKey(LS.filters, id);
|
return { ...DEFAULT_FILTERS };
|
||||||
return fKey ? readMerged(fKey, DEFAULT_FILTERS) : { ...DEFAULT_FILTERS };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -101,6 +103,51 @@ function IconBtn({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||||
|
function displayUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
||||||
|
function SourceRef({ url }: { url: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
notify({ level: "success", message: t("downloads.source.copied") });
|
||||||
|
} catch {
|
||||||
|
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||||
|
<Link2 className="w-3 h-3 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`${t("downloads.source.open")} — ${url}`}
|
||||||
|
className="truncate hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
aria-label={t("downloads.source.copy")}
|
||||||
|
title={t("downloads.source.copy")}
|
||||||
|
className="shrink-0 p-0.5 rounded hover:bg-surface hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DownloadRow({
|
function DownloadRow({
|
||||||
job,
|
job,
|
||||||
children,
|
children,
|
||||||
|
|
@ -131,6 +178,7 @@ function DownloadRow({
|
||||||
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
||||||
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
|
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{job.source_url && <SourceRef url={job.source_url} />}
|
||||||
{running && (
|
{running && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||||
|
|
|
||||||
|
|
@ -306,10 +306,16 @@ export default function Feed({
|
||||||
|
|
||||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||||
loadedRef.current = loaded;
|
loadedRef.current = loaded;
|
||||||
const items: Video[] = loaded
|
const withOverrides = (list: Video[]): Video[] =>
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
list
|
||||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
.filter((v) => matchesView(v.status, filters.show));
|
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
|
||||||
|
const items: Video[] = withOverrides(loaded).filter((v) => matchesView(v.status, filters.show));
|
||||||
|
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
|
||||||
|
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the
|
||||||
|
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
|
||||||
|
// goes hidden still drops out ("visible affects").
|
||||||
|
const playerQueue: Video[] = withOverrides(loaded).filter((v) => v.status !== "hidden");
|
||||||
|
|
||||||
// --- Live YouTube search mode -------------------------------------------------------------
|
// --- Live YouTube search mode -------------------------------------------------------------
|
||||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
||||||
|
|
@ -317,9 +323,8 @@ export default function Feed({
|
||||||
if (ytActive) {
|
if (ytActive) {
|
||||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||||
loadedRef.current = ytLoaded;
|
loadedRef.current = ytLoaded;
|
||||||
const ytItems: Video[] = ytLoaded
|
const ytItems: Video[] = withOverrides(ytLoaded);
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
|
|
||||||
const ytError =
|
const ytError =
|
||||||
ytQuery.error instanceof HttpError
|
ytQuery.error instanceof HttpError
|
||||||
? ytQuery.error.detail || t("feed.yt.error")
|
? ytQuery.error.detail || t("feed.yt.error")
|
||||||
|
|
@ -446,6 +451,7 @@ export default function Feed({
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
|
queue={ytPlayerQueue}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
@ -654,6 +660,7 @@ export default function Feed({
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
|
queue={playerQueue}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
|
|
@ -20,6 +20,14 @@ import { useBackToClose } from "../lib/history";
|
||||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||||
const FINISH_MARGIN = 10;
|
const FINISH_MARGIN = 10;
|
||||||
|
|
||||||
|
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
|
||||||
|
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
|
||||||
|
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
|
||||||
|
type AutoMode = "off" | "next" | "prev" | "random";
|
||||||
|
type LoopMode = "off" | "one" | "all";
|
||||||
|
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||||
|
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||||
|
|
||||||
// --- IFrame Player API loader (singleton) ---
|
// --- IFrame Player API loader (singleton) ---
|
||||||
let apiPromise: Promise<any> | null = null;
|
let apiPromise: Promise<any> | null = null;
|
||||||
function loadYouTubeApi(): Promise<any> {
|
function loadYouTubeApi(): Promise<any> {
|
||||||
|
|
@ -81,7 +89,9 @@ export default function PlayerModal({
|
||||||
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
|
||||||
// playback of the current video.
|
// playback of the current video.
|
||||||
const [playingId, setPlayingId] = useState<string>(
|
const [playingId, setPlayingId] = useState<string>(
|
||||||
queue?.[startIndex ?? 0]?.id ?? video.id
|
// A caller with an explicit index (playlist) starts there; otherwise locate the opened
|
||||||
|
// `video` in the queue (the feed passes its whole list + the clicked video, no index).
|
||||||
|
startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id
|
||||||
);
|
);
|
||||||
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
|
||||||
if (index < 0) index = 0;
|
if (index < 0) index = 0;
|
||||||
|
|
@ -183,6 +193,88 @@ export default function PlayerModal({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
||||||
|
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
||||||
|
// current position, not a stale closure.
|
||||||
|
const navRef = useRef({ queue, index });
|
||||||
|
navRef.current = { queue, index };
|
||||||
|
const goPrev = () => {
|
||||||
|
const { queue: q, index: i } = navRef.current;
|
||||||
|
if (q && i > 0) setPlayingId(q[i - 1].id);
|
||||||
|
};
|
||||||
|
const goNext = () => {
|
||||||
|
const { queue: q, index: i } = navRef.current;
|
||||||
|
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
|
||||||
|
};
|
||||||
|
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||||
|
const nudgeSeek = (delta: number) => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||||
|
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
|
||||||
|
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
|
||||||
|
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
||||||
|
const savedPrefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])
|
||||||
|
?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode };
|
||||||
|
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
||||||
|
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
||||||
|
const modeRef = useRef({ autoMode, loopMode });
|
||||||
|
modeRef.current = { autoMode, loopMode };
|
||||||
|
const persistPref = (patch: Record<string, string>) => {
|
||||||
|
api.savePrefs(patch).catch(() => {});
|
||||||
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||||
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const cycleAuto = () => {
|
||||||
|
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length];
|
||||||
|
setAutoMode(next);
|
||||||
|
persistPref({ playerAutoAdvance: next });
|
||||||
|
};
|
||||||
|
const cycleLoop = () => {
|
||||||
|
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length];
|
||||||
|
setLoopMode(next);
|
||||||
|
persistPref({ playerLoop: next });
|
||||||
|
};
|
||||||
|
const replayCurrent = () => {
|
||||||
|
const p = playerRef.current;
|
||||||
|
if (p && typeof p.seekTo === "function") {
|
||||||
|
p.seekTo(0, true);
|
||||||
|
p.playVideo?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise
|
||||||
|
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
|
||||||
|
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
||||||
|
const advanceOnEnd = () => {
|
||||||
|
const { autoMode: a, loopMode: l } = modeRef.current;
|
||||||
|
if (l === "one") return replayCurrent();
|
||||||
|
const { queue: q, index: i } = navRef.current;
|
||||||
|
if (!q || q.length === 0) return;
|
||||||
|
const wrap = l === "all";
|
||||||
|
if (a === "next") {
|
||||||
|
if (i < q.length - 1) setPlayingId(q[i + 1].id);
|
||||||
|
else if (wrap) {
|
||||||
|
if (q.length === 1) replayCurrent();
|
||||||
|
else setPlayingId(q[0].id);
|
||||||
|
}
|
||||||
|
} else if (a === "prev") {
|
||||||
|
if (i > 0) setPlayingId(q[i - 1].id);
|
||||||
|
else if (wrap) {
|
||||||
|
if (q.length === 1) replayCurrent();
|
||||||
|
else setPlayingId(q[q.length - 1].id);
|
||||||
|
}
|
||||||
|
} else if (a === "random" && q.length > 1) {
|
||||||
|
let r = i;
|
||||||
|
while (r === i) r = Math.floor(Math.random() * q.length);
|
||||||
|
setPlayingId(q[r].id);
|
||||||
|
} else if (a === "random" && wrap) {
|
||||||
|
replayCurrent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Lazy description (fetched only when the title is hovered). The popover is
|
// Lazy description (fetched only when the title is hovered). The popover is
|
||||||
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
|
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
|
||||||
// can't clip it. A small close grace lets the mouse travel title → popover.
|
// can't clip it. A small close grace lets the mouse travel title → popover.
|
||||||
|
|
@ -194,8 +286,10 @@ export default function PlayerModal({
|
||||||
);
|
);
|
||||||
const titleRef = useRef<HTMLSpanElement | null>(null);
|
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const closeTimer = useRef<number | undefined>(undefined);
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const openDesc = () => {
|
const openTimer = useRef<number | undefined>(undefined);
|
||||||
|
const openDescNow = () => {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
const el = titleRef.current;
|
const el = titleRef.current;
|
||||||
if (el) {
|
if (el) {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
|
@ -205,7 +299,15 @@ export default function PlayerModal({
|
||||||
}
|
}
|
||||||
setShowDesc(true);
|
setShowDesc(true);
|
||||||
};
|
};
|
||||||
|
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
|
||||||
|
// quick pass over it doesn't flash the popover.
|
||||||
|
const scheduleOpenDesc = () => {
|
||||||
|
window.clearTimeout(closeTimer.current);
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
|
openTimer.current = window.setTimeout(openDescNow, 400);
|
||||||
|
};
|
||||||
const scheduleCloseDesc = () => {
|
const scheduleCloseDesc = () => {
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||||||
};
|
};
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
|
|
@ -241,6 +343,18 @@ export default function PlayerModal({
|
||||||
if (typing || tag === "button" || tag === "a") return;
|
if (typing || tag === "button" || tag === "a") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||||
|
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||||
|
// previous/next video in the queue (the feed's order or a playlist).
|
||||||
|
if (typing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const forward = e.key === "ArrowRight";
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (forward) goNext();
|
||||||
|
else goPrev();
|
||||||
|
} else {
|
||||||
|
nudgeSeek(forward ? 5 : -5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
@ -251,6 +365,7 @@ export default function PlayerModal({
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
document.body.style.overflow = prevOverflow;
|
document.body.style.overflow = prevOverflow;
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
|
window.clearTimeout(openTimer.current);
|
||||||
window.clearTimeout(volTimerRef.current);
|
window.clearTimeout(volTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
@ -339,7 +454,7 @@ export default function PlayerModal({
|
||||||
autoMarkedRef.current = true;
|
autoMarkedRef.current = true;
|
||||||
setWatched(true);
|
setWatched(true);
|
||||||
}
|
}
|
||||||
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
|
advanceOnEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||||
|
|
@ -380,10 +495,29 @@ export default function PlayerModal({
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
|
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||||||
|
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
||||||
|
the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
|
||||||
|
where there's no room beside the card. */}
|
||||||
|
<div className="relative flex w-full max-w-4xl max-h-full">
|
||||||
|
{hasQueue && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goPrev();
|
||||||
|
}}
|
||||||
|
disabled={index === 0}
|
||||||
|
aria-label={t("player.previous")}
|
||||||
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
|
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
|
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -393,10 +527,11 @@ export default function PlayerModal({
|
||||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||||
through our overlay. */}
|
through our overlay. */}
|
||||||
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
|
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
|
||||||
{/* Interaction layer over the video: catches the scroll wheel (volume) and click
|
{/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume)
|
||||||
(play/pause), and stops the iframe stealing keyboard focus. The bottom strip is
|
and click (play/pause), and stops the iframe stealing keyboard focus. It deliberately
|
||||||
left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen)
|
leaves the top AND bottom edges uncovered so YouTube's native controls — the top-right
|
||||||
stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
cluster (volume / CC / settings) and the bottom bar (seek / More videos / fullscreen)
|
||||||
|
— stay clickable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||||
{playerError == null && (
|
{playerError == null && (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
|
|
@ -406,7 +541,7 @@ export default function PlayerModal({
|
||||||
}}
|
}}
|
||||||
title={t("player.shortcutsHint")}
|
title={t("player.shortcutsHint")}
|
||||||
aria-label={t("player.shortcutsHint")}
|
aria-label={t("player.shortcutsHint")}
|
||||||
className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer"
|
className="absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||||
|
|
@ -444,10 +579,11 @@ export default function PlayerModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||||
>
|
>
|
||||||
<SkipBack className="w-4 h-4" /> {t("player.previous")}
|
<SkipBack className="w-4 h-4" /> {t("player.previous")}
|
||||||
|
|
@ -458,10 +594,39 @@ export default function PlayerModal({
|
||||||
<button
|
<button
|
||||||
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
|
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
|
||||||
disabled={index === queue!.length - 1}
|
disabled={index === queue!.length - 1}
|
||||||
|
title={`${t("player.next")} · Shift+→`}
|
||||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||||
>
|
>
|
||||||
{t("player.next")} <SkipForward className="w-4 h-4" />
|
{t("player.next")} <SkipForward className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<span className="w-px h-4 bg-border" />
|
||||||
|
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||||
|
<button
|
||||||
|
onClick={cycleAuto}
|
||||||
|
title={t("player.autoAdvance.hint")}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||||
|
autoMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{autoMode === "prev" ? (
|
||||||
|
<SkipBack className="h-3.5 w-3.5" />
|
||||||
|
) : autoMode === "random" ? (
|
||||||
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cycleLoop}
|
||||||
|
title={t("player.loop.hint")}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||||
|
loopMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loopMode === "one" ? <Repeat1 className="h-3.5 w-3.5" /> : <Repeat className="h-3.5 w-3.5" />}
|
||||||
|
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -473,7 +638,7 @@ export default function PlayerModal({
|
||||||
<span
|
<span
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={scheduleOpenDesc}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
{navigated ? liveData?.title ?? t("player.loading") : active.title}
|
||||||
|
|
@ -499,7 +664,7 @@ export default function PlayerModal({
|
||||||
bottom: descRect.bottom,
|
bottom: descRect.bottom,
|
||||||
width: descRect.width,
|
width: descRect.width,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={openDescNow}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
|
|
@ -656,6 +821,21 @@ export default function PlayerModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasQueue && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goNext();
|
||||||
|
}}
|
||||||
|
disabled={index >= queue!.length - 1}
|
||||||
|
aria-label={t("player.next")}
|
||||||
|
title={`${t("player.next")} · Shift+→`}
|
||||||
|
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,15 @@ export default function WatchPage() {
|
||||||
|
|
||||||
{status === "ready" && meta && (
|
{status === "ready" && meta && (
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-xl overflow-hidden bg-black border border-slate-800">
|
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||||
<video controls playsInline src={meta.file_url} className="w-full max-h-[75vh] mx-auto block" />
|
player instead of a wide box with black bars; a landscape clip fills the width. */}
|
||||||
|
<div className="rounded-xl overflow-hidden bg-black border border-slate-800 w-fit max-w-full mx-auto">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
src={meta.file_url}
|
||||||
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-start gap-3">
|
<div className="mt-3 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} Elemente",
|
"items": "{{used}} / {{max}} Elemente",
|
||||||
"unlimited": "Unbegrenzt"
|
"unlimited": "Unbegrenzt"
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"open": "Originalseite in neuem Tab öffnen",
|
||||||
|
"copy": "Quell-Link kopieren",
|
||||||
|
"copied": "Quell-Link in die Zwischenablage kopiert",
|
||||||
|
"copyFailed": "Link konnte nicht kopiert werden"
|
||||||
|
},
|
||||||
"rename": {
|
"rename": {
|
||||||
"title": "Download umbenennen",
|
"title": "Download umbenennen",
|
||||||
"label": "Anzeigename",
|
"label": "Anzeigename",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"previous": "Vorheriges",
|
"previous": "Vorheriges",
|
||||||
"next": "Nächstes",
|
"next": "Nächstes",
|
||||||
|
"autoAdvance": {
|
||||||
|
"label": "Auto",
|
||||||
|
"hint": "Was am Videoende folgt. Klicken zum Wechseln: Aus, Nächstes, Vorheriges, Zufall. In deinem Konto gespeichert.",
|
||||||
|
"off": "Aus",
|
||||||
|
"next": "Nächstes",
|
||||||
|
"prev": "Vorheriges",
|
||||||
|
"random": "Zufall"
|
||||||
|
},
|
||||||
|
"loop": {
|
||||||
|
"label": "Wiederholen",
|
||||||
|
"hint": "Aktuelles Video wiederholen (Eins), ganze Liste wiederholen (Alle) oder keines (Aus). In deinem Konto gespeichert.",
|
||||||
|
"off": "Aus",
|
||||||
|
"one": "Eins",
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"closeEsc": "Schließen (Esc)",
|
"closeEsc": "Schließen (Esc)",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} items",
|
"items": "{{used}} / {{max}} items",
|
||||||
"unlimited": "Unlimited"
|
"unlimited": "Unlimited"
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"open": "Open the original page in a new tab",
|
||||||
|
"copy": "Copy source link",
|
||||||
|
"copied": "Source link copied to clipboard",
|
||||||
|
"copyFailed": "Couldn't copy the link"
|
||||||
|
},
|
||||||
"rename": {
|
"rename": {
|
||||||
"title": "Rename download",
|
"title": "Rename download",
|
||||||
"label": "Display name",
|
"label": "Display name",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"autoAdvance": {
|
||||||
|
"label": "Auto",
|
||||||
|
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
|
||||||
|
"off": "Off",
|
||||||
|
"next": "Next",
|
||||||
|
"prev": "Previous",
|
||||||
|
"random": "Random"
|
||||||
|
},
|
||||||
|
"loop": {
|
||||||
|
"label": "Loop",
|
||||||
|
"hint": "Repeat the current video (One), loop the whole list (All), or neither (Off). Saved to your account.",
|
||||||
|
"off": "Off",
|
||||||
|
"one": "One",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closeEsc": "Close (Esc)",
|
"closeEsc": "Close (Esc)",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} elem",
|
"items": "{{used}} / {{max}} elem",
|
||||||
"unlimited": "Korlátlan"
|
"unlimited": "Korlátlan"
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"open": "Eredeti oldal megnyitása új lapon",
|
||||||
|
"copy": "Forráslink másolása",
|
||||||
|
"copied": "Forráslink a vágólapra másolva",
|
||||||
|
"copyFailed": "Nem sikerült a link másolása"
|
||||||
|
},
|
||||||
"rename": {
|
"rename": {
|
||||||
"title": "Letöltés átnevezése",
|
"title": "Letöltés átnevezése",
|
||||||
"label": "Megjelenített név",
|
"label": "Megjelenített név",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
"previous": "Előző",
|
"previous": "Előző",
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
|
"autoAdvance": {
|
||||||
|
"label": "Auto",
|
||||||
|
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
|
||||||
|
"off": "Ki",
|
||||||
|
"next": "Következő",
|
||||||
|
"prev": "Előző",
|
||||||
|
"random": "Véletlen"
|
||||||
|
},
|
||||||
|
"loop": {
|
||||||
|
"label": "Ismétlés",
|
||||||
|
"hint": "Az aktuális videó ismétlése (Egy), a teljes lista körbe (Mind), vagy egyik sem (Ki). A fiókodhoz mentve.",
|
||||||
|
"off": "Ki",
|
||||||
|
"one": "Egy",
|
||||||
|
"all": "Mind"
|
||||||
|
},
|
||||||
"close": "Bezárás",
|
"close": "Bezárás",
|
||||||
"closeEsc": "Bezárás (Esc)",
|
"closeEsc": "Bezárás (Esc)",
|
||||||
"description": "Leírás",
|
"description": "Leírás",
|
||||||
|
|
|
||||||
|
|
@ -698,6 +698,7 @@ export interface DownloadJob {
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
source_ref: string;
|
source_ref: string;
|
||||||
|
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
|
||||||
profile_id: number | null;
|
profile_id: number | null;
|
||||||
spec: DownloadSpec;
|
spec: DownloadSpec;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,22 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.22.3",
|
||||||
|
date: "2026-07-04",
|
||||||
|
summary: "Step through the feed in the player, vertical videos fit, and downloads show their source.",
|
||||||
|
features: [
|
||||||
|
"In-app player: step to the previous/next video in the feed's order — click the arrows beside the player or press Shift+←/→ (plain ←/→ still seek within the video).",
|
||||||
|
"Player auto-advance and loop, saved to your account: when a video ends, play the next, previous or a random one — and loop just that video or the whole list. Works for players opened from the feed and from playlists.",
|
||||||
|
"Every download now shows a “downloaded from” link on the Downloads page — open the original page in a new tab, or copy the link. Works for YouTube and external links (e.g. Facebook reels).",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"The in-app player no longer blocks YouTube's own controls — volume, captions, settings and fullscreen are all clickable again.",
|
||||||
|
"The player's title description now waits a moment before popping up, so it doesn't flash as you move the mouse past.",
|
||||||
|
"Saved views now stick: reloading keeps the view you're on instead of snapping back to your default. (Your default view still loads on a brand-new account.)",
|
||||||
|
"A shared vertical/short video now plays in a player that fits its shape, instead of a wide box with black bars on the sides.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.22.2",
|
version: "0.22.2",
|
||||||
date: "2026-07-04",
|
date: "2026-07-04",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue