diff --git a/deno.lock b/deno.lock index 911f325..02be3c9 100644 --- a/deno.lock +++ b/deno.lock @@ -14,7 +14,9 @@ "npm:react-dom@^19.1.0": "19.2.0_react@19.2.0", "npm:react@^19.1.0": "19.2.0", "npm:tailwindcss@^4.1.14": "4.1.14", + "npm:tauri-plugin-media-api@~0.1.1": "0.1.1", "npm:typescript@~5.8.3": "5.8.3", + "npm:usehooks-ts@^3.1.1": "3.1.1_react@19.2.0", "npm:vite@^7.0.4": "7.1.9_picomatch@4.0.3", "npm:zustand@^5.0.8": "5.0.8_@types+react@19.2.2_react@19.2.0" }, @@ -966,6 +968,9 @@ "lightningcss-win32-x64-msvc" ] }, + "lodash.debounce@4.0.8": { + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ @@ -1088,6 +1093,12 @@ "yallist@5.0.0" ] }, + "tauri-plugin-media-api@0.1.1": { + "integrity": "sha512-mS80IO5gEgUSeVokMYJAqnrE3OWjS3ES7C6tYLNelBkrVhX9iNWKZhI/XB8wyWkNv+mA5wAjRY3t+oEzrDapMQ==", + "dependencies": [ + "@tauri-apps/api" + ] + }, "tinyglobby@0.2.15_picomatch@4.0.3": { "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dependencies": [ @@ -1117,6 +1128,13 @@ "react" ] }, + "usehooks-ts@3.1.1_react@19.2.0": { + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "dependencies": [ + "lodash.debounce", + "react" + ] + }, "vite@7.1.9_picomatch@4.0.3": { "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dependencies": [ @@ -1166,7 +1184,9 @@ "npm:react-dom@^19.1.0", "npm:react@^19.1.0", "npm:tailwindcss@^4.1.14", + "npm:tauri-plugin-media-api@~0.1.1", "npm:typescript@~5.8.3", + "npm:usehooks-ts@^3.1.1", "npm:vite@^7.0.4", "npm:zustand@^5.0.8" ] diff --git a/docs/NOTCH_API_USAGE.md b/docs/NOTCH_API_USAGE.md new file mode 100644 index 0000000..5ed2ddc --- /dev/null +++ b/docs/NOTCH_API_USAGE.md @@ -0,0 +1,107 @@ +# Notch Size API Usage + +## Commands Available + +Two new Tauri commands have been added to retrieve notch information: + +### 1. `get_notch_size` + +Returns the notch dimensions including an estimated width and the actual height from safe area insets. + +**Response Type:** +```typescript +interface NotchSize { + width: number; // Estimated notch width (approximately 12% of screen width) + height: number; // Actual notch height from safe area insets + top_inset: number; // Same as height, the top safe area inset +} +``` + +### 2. `get_safe_area_insets` + +Returns all safe area insets for the main screen. + +**Response Type:** +```typescript +interface SafeAreaInsets { + top: number; // Top inset (includes notch height if present) + bottom: number; // Bottom inset + left: number; // Left inset + right: number; // Right inset +} +``` + +## Usage Examples + +### JavaScript/TypeScript (Tauri v2) + +```typescript +import { invoke } from '@tauri-apps/api/core'; + +// Get notch size +async function getNotchSize() { + try { + const notchSize = await invoke('get_notch_size'); + console.log('Notch dimensions:', notchSize); + // Example output: { width: 220.8, height: 37, top_inset: 37 } + } catch (error) { + console.error('Failed to get notch size:', error); + } +} + +// Get safe area insets +async function getSafeAreaInsets() { + try { + const insets = await invoke('get_safe_area_insets'); + console.log('Safe area insets:', insets); + // Example output: { top: 37, bottom: 0, left: 0, right: 0 } + } catch (error) { + console.error('Failed to get safe area insets:', error); + } +} + +// Use the notch information to position UI elements +async function positionAroundNotch() { + const notchSize = await invoke('get_notch_size'); + + if (notchSize.height > 0) { + // Device has a notch + console.log(`Notch detected: ${notchSize.width}px wide, ${notchSize.height}px tall`); + + // Add padding to avoid the notch area + document.documentElement.style.setProperty('--notch-height', `${notchSize.height}px`); + document.documentElement.style.setProperty('--notch-width', `${notchSize.width}px`); + } else { + console.log('No notch detected'); + } +} +``` + +### CSS Usage + +```css +:root { + --notch-height: 0px; + --notch-width: 0px; +} + +/* Avoid content under the notch */ +.top-bar { + padding-top: var(--notch-height); +} + +/* Position content to the left or right of the notch */ +.left-section { + width: calc((100vw - var(--notch-width)) / 2); +} +``` + +## Notes + +- The notch **height** is accurate and comes directly from `NSScreen.safeAreaInsets.top` +- The notch **width** is estimated at ~12% of screen width since macOS doesn't expose the exact notch width +- For typical MacBook Pros with notches: + - Height: ~32-37 pixels (at 2x scaling) + - Width: ~200-230 pixels (at 2x scaling) +- On devices without a notch, all values will be 0 +- These commands must be called on the main thread (Tauri handles this automatically) diff --git a/package.json b/package.json index 7ec8f75..3d36f87 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "tailwindcss": "^4.1.14", + "tauri-plugin-media-api": "^0.1.1", + "usehooks-ts": "^3.1.1", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5276a47..7517467 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -22,6 +22,12 @@ } ], "sidecar": false + }, + { + "name": "osascript", + "cmd": "osascript", + "args": true, + "sidecar": false } ] } diff --git a/src-tauri/src/commands/get_notch_size.rs b/src-tauri/src/commands/get_notch_size.rs new file mode 100644 index 0000000..0bda7ad --- /dev/null +++ b/src-tauri/src/commands/get_notch_size.rs @@ -0,0 +1,71 @@ +use objc2_app_kit::NSScreen; +use objc2_foundation::MainThreadMarker; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct NotchSize { + pub width: f64, + pub height: f64, + pub top_inset: f64, +} + +#[derive(Debug, Serialize)] +pub struct SafeAreaInsets { + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, +} + +#[tauri::command] +pub fn get_notch_size() -> Result { + // Get the main thread marker (required for AppKit APIs) + let mtm = MainThreadMarker::new().ok_or("Must be called on main thread")?; + + // Get the main screen + let screen = NSScreen::mainScreen(mtm).ok_or("Failed to get main screen")?; + + // Get safe area insets (notch intrudes from the top) + let safe_area = screen.safeAreaInsets(); + + // Get the screen frame to calculate notch width + let frame = screen.frame(); + + // The notch typically spans a portion of the screen width + // For a more accurate width, we'd need to calculate the difference + // between the full width and the safe area, but the safe area + // only gives us top/bottom/left/right insets. + + // The top inset represents the notch height + let notch_height = safe_area.top; + + // For now, we'll estimate the notch width based on typical MacBook Pro dimensions + // In reality, the notch width isn't directly exposed by the API + // A more accurate method would be to measure the menu bar spacing + let notch_width = if notch_height > 0.0 { + // Typical notch width is around 200-230 pixels at 2x scale + frame.size.width * 0.12 // Approximately 12% of screen width + } else { + 0.0 + }; + + Ok(NotchSize { + width: notch_width, + height: notch_height, + top_inset: safe_area.top, + }) +} + +#[tauri::command] +pub fn get_safe_area_insets() -> Result { + let mtm = MainThreadMarker::new().ok_or("Must be called on main thread")?; + let screen = NSScreen::mainScreen(mtm).ok_or("Failed to get main screen")?; + let safe_area = screen.safeAreaInsets(); + + Ok(SafeAreaInsets { + top: safe_area.top, + bottom: safe_area.bottom, + left: safe_area.left, + right: safe_area.right, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6944cf5..608fa44 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod get_app_icon; +pub mod get_notch_size; pub mod set_window_behavior; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e141019..1ca4dd2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ commands::get_app_icon::get_app_icon, + commands::get_notch_size::get_notch_size, + commands::get_notch_size::get_safe_area_insets, commands::set_window_behavior::set_window_behavior ]) .setup(|app| { diff --git a/src/App.tsx b/src/App.tsx index cd7afab..913595c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,15 @@ import './App.css' import AeroSpace from './widgets/AeroSpace' import { TimeWidget } from './widgets/Time' import { Bar } from './components/Bar' +import { Island } from './components/Island' +import { SpotifyWidget } from './widgets/Spotify' function App() { return (
- } right={} /> + } right={} /> +
) } diff --git a/src/components/Island.tsx b/src/components/Island.tsx new file mode 100644 index 0000000..3fc067a --- /dev/null +++ b/src/components/Island.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { useOnClickOutside } from 'usehooks-ts' +import clsx from 'clsx' + +interface NotchSize { + width: number + height: number + top_inset: number +} + +const getNotchSize = async () => { + return await invoke('get_notch_size') +} + +export const Island = ({ + children +}: { + children: React.ReactNode +}) => { + const [notchSize, setNotchSize] = useState(null) + const [show, setShow] = useState(false) + + useEffect(() => { + getNotchSize().then((notch) => { + setNotchSize(notch) + }) + }, []) + + useEffect(() => { + invoke('get_now_playing_info').then((media) => { + console.log(media) + }) + }, []) + + useEffect(() => { + if (show) { + invoke('set_window_behavior', { + alwaysOnTop: true, + alwaysOnBottom: false, + }) + } else { + invoke('set_window_behavior', { + alwaysOnTop: false, + alwaysOnBottom: true + }) + } + }, [show]) + + const ref = useRef(null) + useOnClickOutside(ref as any, () => setShow(false)) + + return
+ +
hello world
+
+ + // return ( + //
setShow(!show)} + // className={clsx( + // 'absolute text-foreground rounded-3xl p-2', show ? 'bg-[#000]' : 'bg-background/50', + // 'transition-all duration-300', + // 'overflow-hidden', + // )} + + // style={{ + // minWidth: show ? (notchSize?.width || 0) + 100 : notchSize?.width, + // height: show ? (notchSize?.height || 0) + 32 : notchSize?.height, + // top: 4, + // left: '50%', + // transform: 'translateX(-50%)', + // }} + // > + // {!show &&
+ //
+ //
} + //
+ // ) +} diff --git a/src/lib/spotify.ts b/src/lib/spotify.ts new file mode 100644 index 0000000..46fd948 --- /dev/null +++ b/src/lib/spotify.ts @@ -0,0 +1,128 @@ +import { Command } from '@tauri-apps/plugin-shell' +import { useEffect, useState } from 'react' +import { useInterval } from 'usehooks-ts' + +type SpotifyTrack = { + name: string + artist: string + album: string + artworkUrl: string + duration: number + id: string +} + +type SpotifyState = { + isRunning: boolean + playerState: 'stopped' | 'playing' | 'paused' + currentTrack: SpotifyTrack | null + playerPosition: number + soundVolume: number + repeating: boolean + shuffling: boolean +} + +export async function executeAppleScript(script: string): Promise { + try { + // Split script into lines and create -e flag for each non-empty line + const lines = script + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + + const args = lines.flatMap(line => ['-e', line]) + + const result = await Command.create('osascript', args).execute() + return result.stdout.trim() + } catch (error) { + console.error('AppleScript error:', error) + return '' + } +} + +export async function getSpotifyState(): Promise { + const isRunningScript = ` + tell application "System Events" + return (name of processes) contains "Spotify" + end tell + ` + const isRunning = (await executeAppleScript(isRunningScript)) === 'true' + + if (!isRunning) { + return { + isRunning: false, + playerState: 'stopped', + currentTrack: null, + playerPosition: 0, + soundVolume: 0, + repeating: false, + shuffling: false, + } + } + + const stateScript = ` + tell application "Spotify" + set trackName to "" + set trackArtist to "" + set trackAlbum to "" + set trackArtworkUrl to "" + set trackDuration to 0 + set trackId to "" + set pState to "stopped" + set pPosition to 0 + set vol to 0 + set rep to false + set shuf to false + + if player state is not stopped then + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackArtworkUrl to artwork url of current track + set trackDuration to duration of current track + set trackId to id of current track + end if + + set pState to player state as string + set pPosition to player position + set vol to sound volume + set rep to repeating + set shuf to shuffling + + return trackName & "|" & trackArtist & "|" & trackAlbum & "|" & trackArtworkUrl & "|" & trackDuration & "|" & trackId & "|" & pState & "|" & pPosition & "|" & vol & "|" & rep & "|" & shuf + end tell + ` + + const result = await executeAppleScript(stateScript) + const [name, artist, album, artworkUrl, duration, id, playerState, playerPosition, soundVolume, repeating, shuffling] = result.split('|') + + return { + isRunning: true, + playerState: playerState as 'stopped' | 'playing' | 'paused', + currentTrack: name ? { + name, + artist, + album, + artworkUrl, + duration: parseInt(duration) || 0, + id, + } : null, + playerPosition: parseFloat(playerPosition) || 0, + soundVolume: parseInt(soundVolume) || 0, + repeating: repeating === 'true', + shuffling: shuffling === 'true', + } +} + +export async function spotifyCommand(command: string): Promise { + await executeAppleScript(`tell application "Spotify" to ${command}`) +} + +export function useSpotifyState(): SpotifyState | null { + const [state, setState] = useState(null) + + useInterval(() => { + getSpotifyState().then(setState) + }, 1000) + + return state +} \ No newline at end of file diff --git a/src/widgets/Spotify.tsx b/src/widgets/Spotify.tsx new file mode 100644 index 0000000..6ab3fd4 --- /dev/null +++ b/src/widgets/Spotify.tsx @@ -0,0 +1,20 @@ +import { Island } from '../components/Island' +import { useSpotifyState } from '../lib/spotify' + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` +} + +export function SpotifyWidget() { + const state = useSpotifyState() + + if (!state) { + return null + } + + return + {state.currentTrack?.name} + +}