Skip to content

Commit

Permalink
feat: add app icons on timeline (macos only)
Browse files Browse the repository at this point in the history
  • Loading branch information
louis030195 committed Nov 6, 2024
1 parent 740557d commit d83d787
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 53 deletions.
22 changes: 16 additions & 6 deletions screenpipe-app-tauri/app/timeline/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -777,10 +777,20 @@ export default function Timeline() {

<div
className="absolute top-0 h-full w-1 bg-foreground/50 shadow-sm opacity-80 z-10"
style={{
left: `${getCurrentTimePercentage()}%`,
}}
/>
style={{ left: `${getCurrentTimePercentage()}%` }}
>
<div className="relative -top-6 right-3 text-[10px] text-muted-foreground whitespace-nowrap">
{frames[currentIndex] &&
new Date(frames[currentIndex].timestamp).toLocaleTimeString(
[],
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}
)}
</div>
</div>

{selectionRange && (
<div
Expand Down Expand Up @@ -952,9 +962,9 @@ export default function Timeline() {
</div>
)}

{/* {loadedTimeRange && frames.length > 0 && (
{loadedTimeRange && frames.length > 0 && (
<TimelineIconsSection blocks={frames} />
)} */}
)}

<div className="relative mt-1 px-2 text-[10px] text-muted-foreground select-none">
{Array(7)
Expand Down
38 changes: 23 additions & 15 deletions screenpipe-app-tauri/components/timeline/timeline-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,30 @@ export function TimelineBlocks({ frames, timeRange }: TimelineBlocksProps) {
// Cache colors to avoid recalculating
const colorCache = useMemo(() => new Map<string, string>(), []);

const getAppColor = (appName: string): string => {
const cached = colorCache.get(appName);
if (cached) return cached;

let hash = 0;
for (let i = 0; i < appName.length; i++) {
hash = appName.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
const color = `hsl(${hue}, 70%, 50%)`;
colorCache.set(appName, color);
return color;
};

// Calculate blocks without sampling
const blocks = useMemo(() => {
const getAppColor = (appName: string): string => {
const cached = colorCache.get(appName);
if (cached) return cached;

// Use a better hash distribution
const hash = Array.from(appName).reduce(
(h, c) => (Math.imul(31, h) + c.charCodeAt(0)) | 0,
0
);

// Use golden ratio for better color distribution
const golden_ratio = 0.618033988749895;
const hue = ((hash * golden_ratio) % 1) * 360;

// Vary saturation and lightness slightly based on hash
const sat = 65 + (hash % 20); // 65-85%
const light = 45 + (hash % 15); // 45-60%

const color = `hsl(${hue}, ${sat}%, ${light}%)`;
colorCache.set(appName, color);
return color;
};
if (frames.length === 0) return [];

const blocks: TimeBlock[] = [];
Expand Down Expand Up @@ -83,7 +91,7 @@ export function TimelineBlocks({ frames, timeRange }: TimelineBlocksProps) {

if (currentBlock) blocks.push(currentBlock);
return blocks;
}, [frames, timeRange, getAppColor]);
}, [frames, timeRange]);

// Debug output
console.log("timeline blocks:", blocks);
Expand Down
123 changes: 92 additions & 31 deletions screenpipe-app-tauri/components/timeline/timeline-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
import { platform } from "@tauri-apps/plugin-os";
import { invoke } from "@tauri-apps/api/core";
import { debounce } from "lodash";
import { StreamTimeSeriesResponse } from "@/app/timeline/page";

interface TimelineDockProps {
children: React.ReactNode;
Expand Down Expand Up @@ -36,6 +37,7 @@ export function TimelineDock({
const handleMouseMove = useMemo(
() =>
debounce((e: React.MouseEvent) => {
if (!e.currentTarget) return;
const bounds = e.currentTarget.getBoundingClientRect();
setMouseX(e.clientX - bounds.left);
}, 16), // ~60fps
Expand Down Expand Up @@ -156,41 +158,100 @@ export const TimelineDockIcon = React.memo(function TimelineDockIcon({
);
});

// Optimize TimelineIconsSection
export function TimelineIconsSection({ blocks }: { blocks: any[] }) {
interface ProcessedBlock {
appName: string;
percentThroughDay: number;
timestamp: Date;
iconSrc?: string;
}

export function TimelineIconsSection({
blocks,
}: {
blocks: StreamTimeSeriesResponse[];
}) {
const [iconCache, setIconCache] = useState<{ [key: string]: string }>({});

// Memoize significant blocks calculation
const significantBlocks = useMemo(
() =>
blocks.filter((block) => {
const minutesInDay = 24 * 60;
const blockMinutes = (block.width / 100) * minutesInDay;
return blockMinutes > 10;
return block.devices.every((device) => device.metadata.app_name);
}),
[blocks]
);

// Memoize unique apps calculation
const uniqueApps = useMemo(
() => [...new Set(significantBlocks.map((block) => block.appName))],
[significantBlocks]
);
// Get the visible time range
const timeRange = useMemo(() => {
if (blocks.length === 0) return null;
const startTime = new Date(blocks[blocks.length - 1].timestamp);
const endTime = new Date(blocks[0].timestamp);
return { start: startTime, end: endTime };
}, [blocks]);

// Memoize processed blocks with position calculations
const processedBlocks = useMemo<ProcessedBlock[]>(() => {
if (!timeRange) return [];

const totalRange = timeRange.end.getTime() - timeRange.start.getTime();

return (
significantBlocks
.map((block) => {
const appName = block.devices.find(
(device) => device.metadata.app_name
)?.metadata.app_name!;
const blockTime = new Date(block.timestamp);

// Calculate percentage based on visible time range
const percentPosition =
((blockTime.getTime() - timeRange.start.getTime()) / totalRange) *
100;

return {
appName,
percentThroughDay: percentPosition,
timestamp: blockTime,
iconSrc: iconCache[appName],
};
})
// Filter out blocks that are too close to each other (less than 1% apart)
.filter((block, index, array) => {
if (index === 0) return true;
const prevBlock = array[index - 1];
const minDuration = 0.4; // 1% minimum gap between icons
return (
Math.abs(block.percentThroughDay - prevBlock.percentThroughDay) >
minDuration
);
})
);
}, [significantBlocks, iconCache, timeRange]);

const loadAppIcon = useCallback(
async (appName: string, appPath?: string) => {
if (iconCache[appName]) return;
try {
// Check platform first to avoid unnecessary invokes
const p = platform();
if (p !== "macos") return; // Early return for unsupported platforms

const icon = await invoke<{ base64: string; path: string } | null>(
"get_app_icon",
{ appName, appPath }
);
if (iconCache[appName]) return;

const icon = await invoke<{ base64: string; path: string } | null>(
"get_app_icon",
{ appName, appPath }
);

if (icon) {
setIconCache((prev) => ({
...prev,
[appName]: icon.base64,
}));
if (icon?.base64) {
// Add null check for base64
setIconCache((prev) => ({
...prev,
[appName]: icon.base64,
}));
}
} catch (error) {
console.error(`failed to load icon for ${appName}:`, error);
// Fail silently - the UI will just not show an icon
}
},
[iconCache]
Expand All @@ -202,29 +263,29 @@ export function TimelineIconsSection({ blocks }: { blocks: any[] }) {
if (p !== "macos") return;

// Load icons for unique app names only
uniqueApps.forEach((appName) => {
loadAppIcon(appName);
processedBlocks.forEach((block) => {
loadAppIcon(block.appName);
});
};

loadIcons();
}, [uniqueApps, loadAppIcon]);
}, [processedBlocks, loadAppIcon]);

return (
<div className="absolute -bottom-20 inset-x-0 pointer-events-none">
{significantBlocks.map((block, i) => (
<div className="absolute -top-12 inset-x-0 pointer-events-none h-8">
{processedBlocks.map((block, i) => (
<div
key={`${block.appName}-${i}`}
className="absolute top-0 h-full"
className="absolute top-0 h-full w-full"
style={{
left: `${block.left}%`,
width: `${block.width}%`,
left: `${block.percentThroughDay}%`,
transform: "translateX(-50%)",
}}
>
{iconCache[block.appName] && (
{block.iconSrc && (
<img
src={`data:image/png;base64,${iconCache[block.appName]}`}
className="w-6 h-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-50"
src={`data:image/png;base64,${block.iconSrc}`}
className="w-5 h-5 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-70"
alt={block.appName}
loading="lazy"
decoding="async"
Expand Down
2 changes: 1 addition & 1 deletion screenpipe-app-tauri/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d83d787

Please sign in to comment.