Compare commits
2 Commits
c00d5c7595
...
6f600c9a5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f600c9a5c | ||
|
|
fc05a40f02 |
@@ -207,6 +207,10 @@ def dismiss_discovery_video(
|
|||||||
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||||
if dq:
|
if dq:
|
||||||
dq.seen = True
|
dq.seen = True
|
||||||
|
|
||||||
|
from ..routers.videos import _update_affinity
|
||||||
|
_update_affinity(db, current_user.id, video, -3.0)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
|
|||||||
existing.score = max(existing.score + delta, -20.0)
|
existing.score = max(existing.score + delta, -20.0)
|
||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
else:
|
else:
|
||||||
if delta > 0:
|
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))
|
||||||
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +252,8 @@ def home_feed(
|
|||||||
v.channel_id,
|
v.channel_id,
|
||||||
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
||||||
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
|
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
|
||||||
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum
|
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
|
||||||
|
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct
|
||||||
FROM videos v
|
FROM videos v
|
||||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||||
GROUP BY v.channel_id
|
GROUP BY v.channel_id
|
||||||
@@ -271,16 +271,19 @@ def home_feed(
|
|||||||
COALESCE(uv.queued, 0) AS queued,
|
COALESCE(uv.queued, 0) AS queued,
|
||||||
uv.rating AS rating,
|
uv.rating AS rating,
|
||||||
NULL AS file_path,
|
NULL AS file_path,
|
||||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 6.0
|
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
||||||
+ COALESCE(cs.liked_count, 0) * 12.0
|
+ COALESCE(cs.liked_count, 0) * 10.0
|
||||||
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel
|
+ COALESCE(cs.rating_sum, 0) * 8.0
|
||||||
|
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel
|
||||||
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
||||||
+ COALESCE((
|
+ COALESCE((
|
||||||
SELECT uta.score FROM user_tag_affinity uta
|
SELECT COALESCE(SUM(uta.score), 0)
|
||||||
|
FROM user_tag_affinity uta
|
||||||
WHERE uta.user_id = :user_id
|
WHERE uta.user_id = :user_id
|
||||||
AND uta.tag = LOWER(COALESCE(v.category, ''))
|
AND (uta.tag = LOWER(COALESCE(v.category, ''))
|
||||||
LIMIT 1
|
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
||||||
), 0) * 3.0 * :w_affinity
|
LIMIT 5
|
||||||
|
), 0) * :w_affinity
|
||||||
AS score,
|
AS score,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY v.channel_id
|
PARTITION BY v.channel_id
|
||||||
@@ -898,6 +901,19 @@ def update_progress(
|
|||||||
if pct < 0.20:
|
if pct < 0.20:
|
||||||
_update_affinity(db, current_user.id, video, -0.5)
|
_update_affinity(db, current_user.id, video, -0.5)
|
||||||
|
|
||||||
|
# Backend safety net: auto-mark watched at ≥90% completion even if the frontend
|
||||||
|
# didn't send watched=True (e.g. browser closed before debounce fired)
|
||||||
|
if (not prev_watched and not uv.watched
|
||||||
|
and uv.completion_percent is not None and uv.completion_percent >= 90
|
||||||
|
and video.duration_seconds and video.duration_seconds > 60):
|
||||||
|
uv.watched = True
|
||||||
|
_update_affinity(db, current_user.id, video, +2.0)
|
||||||
|
dl = db.query(Download).filter_by(
|
||||||
|
user_id=current_user.id, video_id=video_id, status="complete"
|
||||||
|
).filter(Download.pending_delete_at.is_(None)).first()
|
||||||
|
if dl:
|
||||||
|
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function BottomNav({ newCount }) {
|
|||||||
to={tab.to}
|
to={tab.to}
|
||||||
end={tab.end}
|
end={tab.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
|
||||||
isActive ? "text-accent" : "text-zinc-500"
|
isActive ? "text-accent" : "text-zinc-500"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ export default function VideoPlayer() {
|
|||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [downloadId, setDownloadId] = useState(null);
|
const [downloadId, setDownloadId] = useState(null);
|
||||||
const [switchedToLocal, setSwitchedToLocal] = useState(false);
|
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const initiatedRef = useRef(null); // track which video we triggered download for
|
const initiatedRef = useRef(null);
|
||||||
|
|
||||||
// ── Video metadata ────────────────────────────────────────────────────────
|
// ── Video metadata ────────────────────────────────────────────────────────
|
||||||
const { data: video, refetch: refetchVideo } = useQuery({
|
const { data: video, refetch: refetchVideo } = useQuery({
|
||||||
@@ -119,14 +118,13 @@ export default function VideoPlayer() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// When download finishes, re-fetch video to get local_file_url and auto-switch
|
// When download finishes, refetch video — local_file_url will appear once the
|
||||||
|
// file exists on disk, which is the single source of truth for switching players
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dlStatus?.status === "complete" && !switchedToLocal) {
|
if (dlStatus?.status === "complete" && !video?.local_file_url) {
|
||||||
refetchVideo().then(({ data }) => {
|
refetchVideo();
|
||||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
|
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Trigger download on open ──────────────────────────────────────────────
|
// ── Trigger download on open ──────────────────────────────────────────────
|
||||||
const downloadMut = useMutation({
|
const downloadMut = useMutation({
|
||||||
@@ -134,22 +132,16 @@ export default function VideoPlayer() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const dl = res.data;
|
const dl = res.data;
|
||||||
setDownloadId(dl.id);
|
setDownloadId(dl.id);
|
||||||
// If it came back complete already (was pre-downloaded), just switch now
|
// If already complete (pre-downloaded), refetch to get local_file_url
|
||||||
if (dl.status === "complete") {
|
if (dl.status === "complete") refetchVideo();
|
||||||
refetchVideo().then(({ data }) => {
|
|
||||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
||||||
initiatedRef.current = youtubeId;
|
initiatedRef.current = youtubeId;
|
||||||
setSwitchedToLocal(false);
|
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDownloadId(null);
|
setDownloadId(null);
|
||||||
// Small delay so the modal renders before the fetch starts
|
|
||||||
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -174,7 +166,6 @@ export default function VideoPlayer() {
|
|||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
||||||
setSwitchedToLocal(false);
|
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
}, [setParams]);
|
}, [setParams]);
|
||||||
|
|
||||||
@@ -192,7 +183,9 @@ export default function VideoPlayer() {
|
|||||||
const channelName = video?.channel_name ?? urlChannel;
|
const channelName = video?.channel_name ?? urlChannel;
|
||||||
const startAt = video?.watch_progress_seconds ?? 0;
|
const startAt = video?.watch_progress_seconds ?? 0;
|
||||||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
||||||
const localUrl = switchedToLocal ? video?.local_file_url : null;
|
// local_file_url is only set by the backend when the file actually exists on disk
|
||||||
|
const localUrl = video?.local_file_url ?? null;
|
||||||
|
const videoLoading = !video;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -216,8 +209,15 @@ export default function VideoPlayer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Player — local file once ready, YouTube embed while downloading */}
|
{/* Player — wait for metadata, then show local file or YouTube embed */}
|
||||||
{localUrl ? (
|
{videoLoading ? (
|
||||||
|
<div className="w-full aspect-video rounded-lg bg-zinc-900 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 animate-spin text-zinc-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : localUrl ? (
|
||||||
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
||||||
) : (
|
) : (
|
||||||
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ export default function ChannelPage() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
|
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{sortVideos(videos, videoSort).map((v) => (
|
{sortVideos(videos, videoSort).map((v) => (
|
||||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
|
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -34,14 +34,12 @@ export default function ContinueWatchingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
|
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{videos.map((v) => (
|
{videos.map((v) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={v.youtube_video_id}
|
key={v.youtube_video_id}
|
||||||
video={{
|
video={{ ...v, is_watched: false }}
|
||||||
...v,
|
variant="list"
|
||||||
is_watched: false,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -324,11 +324,12 @@ export default function DiscoveryPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{visibleVideos.map((v) => (
|
{visibleVideos.map((v) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={v.youtube_video_id}
|
key={v.youtube_video_id}
|
||||||
video={{ ...v, is_recommended: true }}
|
video={{ ...v, is_recommended: true }}
|
||||||
|
variant="list"
|
||||||
onDismiss={handleDismissVideo}
|
onDismiss={handleDismissVideo}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1006,8 +1006,8 @@ export default function Following() {
|
|||||||
<p className="text-zinc-500 text-sm">No videos indexed yet — hit Sync all to pull the latest from YouTube.</p>
|
<p className="text-zinc-500 text-sm">No videos indexed yet — hit Sync all to pull the latest from YouTube.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} />)}
|
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} variant="list" />)}
|
||||||
</div>
|
</div>
|
||||||
{hasMoreFeed && (
|
{hasMoreFeed && (
|
||||||
<div className="flex justify-center mt-2">
|
<div className="flex justify-center mt-2">
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export default function History() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{videos.map((v) => (
|
{videos.map((v) => (
|
||||||
<VideoCard key={v.youtube_video_id} video={v} />
|
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 pt-2">
|
<div className="flex items-center justify-center gap-3 pt-2">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function Home() {
|
|||||||
const [dismissed, setDismissed] = useState(new Set());
|
const [dismissed, setDismissed] = useState(new Set());
|
||||||
const [shuffleKey, setShuffleKey] = useState(0);
|
const [shuffleKey, setShuffleKey] = useState(0);
|
||||||
const [duration, setDuration] = useState("");
|
const [duration, setDuration] = useState("");
|
||||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid");
|
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "list");
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
const next = viewMode === "grid" ? "list" : "grid";
|
const next = viewMode === "grid" ? "list" : "grid";
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export default function LikedPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{sortLiked(videos, sort).map((v) => (
|
{sortLiked(videos, sort).map((v) => (
|
||||||
<VideoCard key={v.youtube_video_id} video={v} />
|
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ export default function QueuePage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
|
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{videos.map((v) => (
|
{videos.map((v) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={v.youtube_video_id}
|
key={v.youtube_video_id}
|
||||||
video={v}
|
video={v}
|
||||||
|
variant="list"
|
||||||
onRemoveFromQueue={() => {
|
onRemoveFromQueue={() => {
|
||||||
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
|
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ export default function SearchResults() {
|
|||||||
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
|
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
{visibleVideos.map((v) => (
|
{visibleVideos.map((v) => (
|
||||||
<VideoCard key={v.youtube_video_id} video={v} />
|
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ function DescriptionBox({ text }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-zinc-900 rounded-xl p-4 cursor-pointer select-none"
|
className="bg-zinc-900 rounded-xl p-3 sm:p-4 cursor-pointer select-none"
|
||||||
onClick={() => hasMore && setExpanded(v => !v)}
|
onClick={() => hasMore && setExpanded(v => !v)}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-zinc-300 whitespace-pre-line leading-relaxed">
|
<p className="text-[13px] text-zinc-300 whitespace-pre-line leading-relaxed">
|
||||||
{linkify(displayed)}
|
{linkify(displayed)}
|
||||||
</p>
|
</p>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
@@ -94,7 +94,7 @@ function Chip({ onClick, active, disabled, children }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors",
|
"flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||||
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
|
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
|
||||||
disabled && "opacity-40 cursor-not-allowed",
|
disabled && "opacity-40 cursor-not-allowed",
|
||||||
].filter(Boolean).join(" ")}
|
].filter(Boolean).join(" ")}
|
||||||
@@ -844,7 +844,7 @@ export default function Watch() {
|
|||||||
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
|
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
|
||||||
|
|
||||||
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
{/* ── Left: video + info ───────────────────────────────────────────── */}
|
||||||
<div className={theater ? "w-full flex flex-col gap-4" : "flex-1 min-w-0 flex flex-col gap-4"}>
|
<div className={theater ? "w-full flex flex-col gap-3 sm:gap-4" : "flex-1 min-w-0 flex flex-col gap-3 sm:gap-4"}>
|
||||||
|
|
||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
|
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
|
||||||
@@ -887,11 +887,11 @@ export default function Watch() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-xl font-bold text-white leading-snug">{title}</h1>
|
<h1 className="text-base sm:text-xl font-bold text-white leading-snug">{title}</h1>
|
||||||
|
|
||||||
{/* Meta + actions row */}
|
{/* Meta + actions row */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap">
|
<div className="flex items-center gap-2 text-xs text-zinc-500 flex-wrap">
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
||||||
{video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>}
|
{video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>}
|
||||||
@@ -914,7 +914,7 @@ export default function Watch() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
{!dlComplete && !isDownloading && !downloadMut.isPending && (
|
||||||
<select
|
<select
|
||||||
value={selectedQuality ?? "best"}
|
value={selectedQuality ?? "best"}
|
||||||
@@ -1048,9 +1048,9 @@ export default function Watch() {
|
|||||||
<Link to={`/channels/${video?.channel_id}`} className="shrink-0">
|
<Link to={`/channels/${video?.channel_id}`} className="shrink-0">
|
||||||
{channel?.thumbnail_url ? (
|
{channel?.thumbnail_url ? (
|
||||||
<img src={channel.thumbnail_url} alt={channelName}
|
<img src={channel.thumbnail_url} alt={channelName}
|
||||||
className="w-11 h-11 rounded-full object-cover" />
|
className="w-9 h-9 sm:w-11 sm:h-11 rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-11 h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
|
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
|
||||||
style={{ backgroundColor: avatarColor(channelName) }}>
|
style={{ backgroundColor: avatarColor(channelName) }}>
|
||||||
{channelName?.[0]?.toUpperCase()}
|
{channelName?.[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
@@ -1084,7 +1084,7 @@ export default function Watch() {
|
|||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="hidden sm:flex flex-wrap gap-1.5">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
|
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
@@ -1107,7 +1107,7 @@ export default function Watch() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keyboard shortcuts hint */}
|
{/* Keyboard shortcuts hint */}
|
||||||
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
<p className={`hidden sm:block text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||||||
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user