@leg3ndy/otto-bridge 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -221,6 +221,136 @@ function descriptionWantsPause(description) {
221
221
  function descriptionWantsResume(description) {
222
222
  return /\b(retoma|retomar|resume|continu[ae]r|despausa|play)\b/.test(normalizeText(description || ""));
223
223
  }
224
+ function descriptionWantsSpotifyTrackPageOpen(description) {
225
+ const normalized = normalizeText(description || "");
226
+ return /\bspotify\b/.test(normalized)
227
+ && /\b(pagina|titulo|nome|link)\b/.test(normalized)
228
+ && /\b(faixa|musica|track)\b/.test(normalized);
229
+ }
230
+ function descriptionWantsSpotifyTrackPagePlay(description) {
231
+ const normalized = normalizeText(description || "");
232
+ return /\bspotify\b/.test(normalized)
233
+ && /\b(play|reproduzir|tocar|verde|principal)\b/.test(normalized)
234
+ && /\b(pagina|faixa|musica|track)\b/.test(normalized);
235
+ }
236
+ function isSpotifySafariDomOnlyStep(description) {
237
+ return descriptionWantsSpotifyTrackPageOpen(description) || descriptionWantsSpotifyTrackPagePlay(description);
238
+ }
239
+ function playerStateLooksActive(playerState) {
240
+ const normalized = normalizeText(playerState || "");
241
+ return normalized.includes("pause") || normalized.includes("pausar");
242
+ }
243
+ function browserStateMatchesSpotifyTrackDescription(state, description) {
244
+ if (!state) {
245
+ return false;
246
+ }
247
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
248
+ if (!normalizedUrl.includes("open.spotify.com/track/")) {
249
+ return false;
250
+ }
251
+ const haystack = [
252
+ state.playerTitle || "",
253
+ state.title || "",
254
+ String(state.text || "").slice(0, 4000),
255
+ ].join(" ");
256
+ const normalizedHaystack = normalizeText(haystack);
257
+ if (!normalizedHaystack) {
258
+ return false;
259
+ }
260
+ const quotedPhrases = extractQuotedPhrases(description);
261
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
262
+ return true;
263
+ }
264
+ const mediaQueryTokens = extractMediaQueryTokens(description);
265
+ if (!mediaQueryTokens.length) {
266
+ return false;
267
+ }
268
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
269
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
270
+ }
271
+ function spotifyStateContentMatchesDescription(state, description) {
272
+ if (!state) {
273
+ return false;
274
+ }
275
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
276
+ if (!normalizedUrl.includes("open.spotify.com")) {
277
+ return false;
278
+ }
279
+ const haystack = [
280
+ state.playerTitle || "",
281
+ state.title || "",
282
+ String(state.text || "").slice(0, 4000),
283
+ ].join(" ");
284
+ const normalizedHaystack = normalizeText(haystack);
285
+ if (!normalizedHaystack) {
286
+ return false;
287
+ }
288
+ const quotedPhrases = extractQuotedPhrases(description);
289
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
290
+ return true;
291
+ }
292
+ const mediaQueryTokens = extractMediaQueryTokens(description);
293
+ if (!mediaQueryTokens.length) {
294
+ return false;
295
+ }
296
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
297
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
298
+ }
299
+ function spotifyPlayerAlreadyPlayingMatchesDescription(state, description) {
300
+ if (!state) {
301
+ return false;
302
+ }
303
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
304
+ if (!normalizedUrl.includes("open.spotify.com")) {
305
+ return false;
306
+ }
307
+ if (!playerStateLooksActive(state.playerState || "")) {
308
+ return false;
309
+ }
310
+ const haystack = [
311
+ state.playerTitle || "",
312
+ state.title || "",
313
+ String(state.text || "").slice(0, 4000),
314
+ ].join(" ");
315
+ const normalizedHaystack = normalizeText(haystack);
316
+ if (!normalizedHaystack) {
317
+ return false;
318
+ }
319
+ const quotedPhrases = extractQuotedPhrases(description);
320
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
321
+ return true;
322
+ }
323
+ const mediaQueryTokens = extractMediaQueryTokens(description);
324
+ if (!mediaQueryTokens.length) {
325
+ return false;
326
+ }
327
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
328
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
329
+ }
330
+ function spotifyTrackAlreadyPlayingMatchesDescription(state, description) {
331
+ return browserStateMatchesSpotifyTrackDescription(state, description)
332
+ && spotifyPlayerAlreadyPlayingMatchesDescription(state, description);
333
+ }
334
+ function spotifyTrackPageLooksPlaying(state, description) {
335
+ return browserStateMatchesSpotifyTrackDescription(state, description)
336
+ && playerStateLooksActive(state?.playerState || "");
337
+ }
338
+ function spotifyDescriptionsLikelyMatch(left, right) {
339
+ const leftPhrases = new Set(extractQuotedPhrases(left));
340
+ const rightPhrases = new Set(extractQuotedPhrases(right));
341
+ for (const phrase of leftPhrases) {
342
+ if (rightPhrases.has(phrase)) {
343
+ return true;
344
+ }
345
+ }
346
+ const leftTokens = extractMediaQueryTokens(left);
347
+ const rightTokens = new Set(extractMediaQueryTokens(right));
348
+ if (!leftTokens.length || !rightTokens.size) {
349
+ return false;
350
+ }
351
+ const overlap = leftTokens.filter((token) => rightTokens.has(token)).length;
352
+ return overlap >= Math.max(1, Math.ceil(Math.min(leftTokens.length, rightTokens.size) * 0.5));
353
+ }
224
354
  function extractNativeMediaTransportCommand(description) {
225
355
  const normalizedDescription = normalizeText(description || "");
226
356
  if (!normalizedDescription
@@ -473,6 +603,10 @@ function urlHostname(url) {
473
603
  return null;
474
604
  }
475
605
  }
606
+ function isSpotifySearchUrl(url) {
607
+ const normalized = normalizeComparableUrl(normalizeUrl(url));
608
+ return normalized.includes("open.spotify.com/search/");
609
+ }
476
610
  function uniqueStrings(values) {
477
611
  const seen = new Set();
478
612
  const result = [];
@@ -971,6 +1105,9 @@ export class NativeMacOSJobExecutor {
971
1105
  lastActiveApp = null;
972
1106
  lastVisualTargetDescription = null;
973
1107
  lastVisualTargetApp = null;
1108
+ lastSatisfiedSpotifyDescription = null;
1109
+ lastSatisfiedSpotifyConfirmedPlaying = false;
1110
+ lastSatisfiedSpotifyAt = 0;
974
1111
  whatsappBackgroundBrowser = null;
975
1112
  whatsappRuntimeMonitor = null;
976
1113
  constructor(bridgeConfig) {
@@ -987,6 +1124,9 @@ export class NativeMacOSJobExecutor {
987
1124
  if (actions.length === 0) {
988
1125
  throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
989
1126
  }
1127
+ this.lastSatisfiedSpotifyDescription = null;
1128
+ this.lastSatisfiedSpotifyConfirmedPlaying = false;
1129
+ this.lastSatisfiedSpotifyAt = 0;
990
1130
  await reporter.accepted();
991
1131
  const confirmation = extractConfirmationOptions(job, actions);
992
1132
  if (confirmation.required) {
@@ -1025,7 +1165,7 @@ export class NativeMacOSJobExecutor {
1025
1165
  }
1026
1166
  if (action.type === "press_shortcut") {
1027
1167
  await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
1028
- await this.pressShortcut(action.shortcut);
1168
+ const shortcutResult = await this.pressShortcut(action.shortcut);
1029
1169
  if (action.shortcut.startsWith("media_")) {
1030
1170
  const mediaSummaryMap = {
1031
1171
  media_next: "Acionei o comando de próxima mídia no macOS.",
@@ -1035,7 +1175,14 @@ export class NativeMacOSJobExecutor {
1035
1175
  media_play: "Acionei o comando de reproduzir mídia no macOS.",
1036
1176
  media_play_pause: "Acionei o comando de play/pause de mídia no macOS.",
1037
1177
  };
1038
- completionNotes.push(mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`);
1178
+ const mediaSkippedSummaryMap = {
1179
+ media_pause: "Nenhuma mídia estava tocando no macOS, então o pause global foi pulado.",
1180
+ media_resume: "Já havia mídia tocando no macOS, então o comando de retomar foi pulado.",
1181
+ media_play: "Já havia mídia tocando no macOS, então o comando de reproduzir foi pulado.",
1182
+ };
1183
+ completionNotes.push(shortcutResult.performed
1184
+ ? (mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`)
1185
+ : (mediaSkippedSummaryMap[action.shortcut] || shortcutResult.reason || `O atalho ${action.shortcut} foi pulado no macOS.`));
1039
1186
  }
1040
1187
  continue;
1041
1188
  }
@@ -1239,7 +1386,9 @@ export class NativeMacOSJobExecutor {
1239
1386
  await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
1240
1387
  await this.focusApp(action.app);
1241
1388
  }
1242
- const targetDescriptions = uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1389
+ const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
1390
+ ? [action.description]
1391
+ : uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1243
1392
  let clickSucceeded = false;
1244
1393
  let lastFailureReason = "";
1245
1394
  for (let attempt = 0; attempt < targetDescriptions.length; attempt += 1) {
@@ -1247,6 +1396,33 @@ export class NativeMacOSJobExecutor {
1247
1396
  const initialBrowserState = browserApp
1248
1397
  ? await this.captureBrowserPageState(browserApp).catch(() => null)
1249
1398
  : null;
1399
+ const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
1400
+ const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
1401
+ if (isSpotifySafariStep) {
1402
+ await reporter.progress(progressPercent, `Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
1403
+ const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
1404
+ if (spotifyDomResult.ok) {
1405
+ this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
1406
+ this.lastVisualTargetDescription = targetDescription;
1407
+ this.lastVisualTargetApp = browserApp || action.app || this.lastActiveApp;
1408
+ resultPayload.last_click = {
1409
+ strategy: spotifyDomResult.skipped
1410
+ ? "spotify_browser_state_skip"
1411
+ : (spotifyDomResult.click?.strategy || "spotify_safari_dom"),
1412
+ matched_text: spotifyDomResult.click?.matchedText || targetDescription,
1413
+ matched_href: spotifyDomResult.click?.matchedHref || spotifyDomResult.afterState?.url || null,
1414
+ score: spotifyDomResult.click?.score || null,
1415
+ total_candidates: spotifyDomResult.click?.totalCandidates || null,
1416
+ };
1417
+ completionNotes.push(spotifyDomResult.skipped
1418
+ ? `O Spotify Web ja estava no estado certo para ${targetDescription}, então pulei tentativas extras.`
1419
+ : `Concluí ${targetDescription} diretamente pelo DOM do Spotify Web no Safari.`);
1420
+ clickSucceeded = true;
1421
+ break;
1422
+ }
1423
+ lastFailureReason = spotifyDomResult.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`;
1424
+ continue;
1425
+ }
1250
1426
  const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
1251
1427
  if (nativeMediaTransport) {
1252
1428
  await reporter.progress(progressPercent, `Tentando controle de mídia nativo do macOS para ${targetDescription}`);
@@ -1259,8 +1435,8 @@ export class NativeMacOSJobExecutor {
1259
1435
  validated = browserValidation.ok;
1260
1436
  validationReason = browserValidation.reason;
1261
1437
  }
1262
- if (!validated && action.verification_prompt) {
1263
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1438
+ if (!validated && verificationPrompt) {
1439
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1264
1440
  if (verification.unavailable) {
1265
1441
  lastFailureReason = verification.reason;
1266
1442
  break;
@@ -1298,8 +1474,8 @@ export class NativeMacOSJobExecutor {
1298
1474
  validated = browserValidation.ok;
1299
1475
  validationReason = browserValidation.reason;
1300
1476
  }
1301
- if (!validated && action.verification_prompt) {
1302
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "dom_click_result");
1477
+ if (!validated && verificationPrompt) {
1478
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "dom_click_result");
1303
1479
  if (verification.unavailable) {
1304
1480
  lastFailureReason = verification.reason;
1305
1481
  break;
@@ -1344,8 +1520,8 @@ export class NativeMacOSJobExecutor {
1344
1520
  validated = browserValidation.ok;
1345
1521
  validationReason = browserValidation.reason;
1346
1522
  }
1347
- if (!validated && action.verification_prompt) {
1348
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1523
+ if (!validated && verificationPrompt) {
1524
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1349
1525
  if (verification.unavailable) {
1350
1526
  lastFailureReason = verification.reason;
1351
1527
  break;
@@ -1425,8 +1601,8 @@ export class NativeMacOSJobExecutor {
1425
1601
  y: scaledY,
1426
1602
  strategy: "visual_locator",
1427
1603
  };
1428
- if (action.verification_prompt) {
1429
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "visual_click_result");
1604
+ if (verificationPrompt) {
1605
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "visual_click_result");
1430
1606
  if (verification.unavailable) {
1431
1607
  lastFailureReason = verification.reason;
1432
1608
  break;
@@ -1531,6 +1707,9 @@ export class NativeMacOSJobExecutor {
1531
1707
  await this.runCommand("open", ["-a", app, url]);
1532
1708
  }
1533
1709
  await this.focusApp(app);
1710
+ if (isSpotifySearchUrl(url)) {
1711
+ await this.ensureSafariSpotifySearchUrl(url);
1712
+ }
1534
1713
  this.lastActiveApp = app;
1535
1714
  return;
1536
1715
  }
@@ -1542,6 +1721,48 @@ export class NativeMacOSJobExecutor {
1542
1721
  }
1543
1722
  await this.runCommand("open", [url]);
1544
1723
  }
1724
+ async ensureSafariSpotifySearchUrl(url) {
1725
+ const targetUrl = normalizeUrl(url);
1726
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1727
+ await delay(700);
1728
+ const currentUrl = await this.readSafariCurrentUrl().catch(() => "");
1729
+ const normalizedCurrentUrl = normalizeComparableUrl(currentUrl);
1730
+ if (!normalizedCurrentUrl || !normalizedCurrentUrl.includes("open.spotify.com")) {
1731
+ return;
1732
+ }
1733
+ if (normalizedCurrentUrl.includes("/search/")) {
1734
+ return;
1735
+ }
1736
+ if (!/open\.spotify\.com\/?$/.test(normalizedCurrentUrl)) {
1737
+ return;
1738
+ }
1739
+ await this.setSafariFrontTabUrl(targetUrl).catch(() => undefined);
1740
+ }
1741
+ }
1742
+ async readSafariCurrentUrl() {
1743
+ const script = `
1744
+ tell application "Safari"
1745
+ if (count of windows) = 0 then return ""
1746
+ try
1747
+ return URL of current tab of front window
1748
+ on error
1749
+ return ""
1750
+ end try
1751
+ end tell
1752
+ `;
1753
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
1754
+ return String(stdout || "").trim();
1755
+ }
1756
+ async setSafariFrontTabUrl(url) {
1757
+ const script = `
1758
+ set targetUrl to "${escapeAppleScript(url)}"
1759
+ tell application "Safari"
1760
+ if (count of windows) = 0 then return
1761
+ set URL of current tab of front window to targetUrl
1762
+ end tell
1763
+ `;
1764
+ await this.runCommand("osascript", ["-e", script]);
1765
+ }
1545
1766
  async tryReuseSafariTab(url) {
1546
1767
  const targetUrl = normalizeUrl(url);
1547
1768
  const targetHost = urlHostname(targetUrl);
@@ -1651,6 +1872,161 @@ end tell
1651
1872
  playerState: page.playerState || "",
1652
1873
  };
1653
1874
  }
1875
+ rememberSatisfiedSpotifyStep(description, confirmedPlaying) {
1876
+ if (!isSpotifySafariDomOnlyStep(description)) {
1877
+ return;
1878
+ }
1879
+ this.lastSatisfiedSpotifyDescription = description;
1880
+ this.lastSatisfiedSpotifyConfirmedPlaying = confirmedPlaying;
1881
+ this.lastSatisfiedSpotifyAt = Date.now();
1882
+ }
1883
+ hasRecentSatisfiedSpotifyPlayback(description, maxAgeMs = 10_000) {
1884
+ if (!this.lastSatisfiedSpotifyConfirmedPlaying || !this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1885
+ return false;
1886
+ }
1887
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1888
+ return false;
1889
+ }
1890
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1891
+ }
1892
+ hasRecentSatisfiedSpotifyStep(description, maxAgeMs = 10_000) {
1893
+ if (!this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1894
+ return false;
1895
+ }
1896
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1897
+ return false;
1898
+ }
1899
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1900
+ }
1901
+ async executeSpotifySafariDomStep(targetDescription, initialBrowserState) {
1902
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)
1903
+ && this.hasRecentSatisfiedSpotifyStep(targetDescription)
1904
+ && browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription)) {
1905
+ return {
1906
+ ok: true,
1907
+ skipped: true,
1908
+ afterState: initialBrowserState,
1909
+ };
1910
+ }
1911
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription) && this.hasRecentSatisfiedSpotifyPlayback(targetDescription)) {
1912
+ return {
1913
+ ok: true,
1914
+ skipped: true,
1915
+ confirmedPlaying: true,
1916
+ afterState: initialBrowserState,
1917
+ };
1918
+ }
1919
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1920
+ return {
1921
+ ok: true,
1922
+ skipped: true,
1923
+ confirmedPlaying: true,
1924
+ afterState: initialBrowserState,
1925
+ };
1926
+ }
1927
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1928
+ const shouldWaitLongerForAutoplay = this.hasRecentSatisfiedSpotifyStep(targetDescription);
1929
+ let settledState = initialBrowserState;
1930
+ for (let attempt = 0; attempt < (shouldWaitLongerForAutoplay ? 6 : 3); attempt += 1) {
1931
+ if (attempt > 0) {
1932
+ await delay(shouldWaitLongerForAutoplay ? 550 : 450);
1933
+ }
1934
+ settledState = await this.captureBrowserPageState("Safari").catch(() => settledState);
1935
+ const playbackState = await this.readGlobalMediaPlaybackState();
1936
+ if ((playbackState === "playing" && spotifyStateContentMatchesDescription(settledState, targetDescription))
1937
+ || spotifyTrackPageLooksPlaying(settledState, targetDescription)
1938
+ || spotifyTrackAlreadyPlayingMatchesDescription(settledState, targetDescription)) {
1939
+ return {
1940
+ ok: true,
1941
+ skipped: true,
1942
+ confirmedPlaying: true,
1943
+ afterState: settledState,
1944
+ };
1945
+ }
1946
+ }
1947
+ const alreadyOnRequestedTrack = browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription);
1948
+ if (alreadyOnRequestedTrack) {
1949
+ const playbackState = await this.readGlobalMediaPlaybackState();
1950
+ if (playbackState === "playing"
1951
+ || spotifyTrackPageLooksPlaying(initialBrowserState, targetDescription)
1952
+ || spotifyTrackAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1953
+ return {
1954
+ ok: true,
1955
+ skipped: true,
1956
+ confirmedPlaying: true,
1957
+ afterState: initialBrowserState,
1958
+ };
1959
+ }
1960
+ }
1961
+ }
1962
+ const domClick = await this.trySafariDomClick(targetDescription);
1963
+ let currentState = await this.captureBrowserPageState("Safari").catch(() => initialBrowserState);
1964
+ if (!domClick?.clicked) {
1965
+ return {
1966
+ ok: false,
1967
+ reason: domClick?.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`,
1968
+ afterState: currentState,
1969
+ };
1970
+ }
1971
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1972
+ if (attempt > 0) {
1973
+ await delay(500);
1974
+ }
1975
+ currentState = await this.captureBrowserPageState("Safari").catch(() => currentState);
1976
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
1977
+ if (browserStateMatchesSpotifyTrackDescription(currentState, targetDescription)) {
1978
+ return {
1979
+ ok: true,
1980
+ click: domClick,
1981
+ afterState: currentState,
1982
+ };
1983
+ }
1984
+ const playbackState = await this.readGlobalMediaPlaybackState();
1985
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
1986
+ return {
1987
+ ok: true,
1988
+ click: domClick,
1989
+ confirmedPlaying: true,
1990
+ afterState: currentState,
1991
+ };
1992
+ }
1993
+ }
1994
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1995
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(currentState, targetDescription)) {
1996
+ return {
1997
+ ok: true,
1998
+ click: domClick,
1999
+ confirmedPlaying: true,
2000
+ afterState: currentState,
2001
+ };
2002
+ }
2003
+ const playbackState = await this.readGlobalMediaPlaybackState();
2004
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
2005
+ return {
2006
+ ok: true,
2007
+ click: domClick,
2008
+ confirmedPlaying: true,
2009
+ afterState: currentState,
2010
+ };
2011
+ }
2012
+ }
2013
+ }
2014
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
2015
+ const matchedHref = normalizeComparableUrl(domClick.matchedHref || "");
2016
+ if (matchedHref.includes("/track/")) {
2017
+ return {
2018
+ ok: true,
2019
+ click: domClick,
2020
+ afterState: currentState,
2021
+ };
2022
+ }
2023
+ }
2024
+ return {
2025
+ ok: false,
2026
+ reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
2027
+ afterState: currentState,
2028
+ };
2029
+ }
1654
2030
  resolveExpectedBrowserHref(rawHref, baseUrl) {
1655
2031
  const href = String(rawHref || "").trim();
1656
2032
  if (!href) {
@@ -1691,12 +2067,14 @@ end tell
1691
2067
  const afterPlayerTitle = normalizeText(after.playerTitle || "");
1692
2068
  const beforePlayerState = normalizeText(before?.playerState || "");
1693
2069
  const afterPlayerState = normalizeText(after.playerState || "");
1694
- const playerLooksActive = afterPlayerState.includes("pause") || afterPlayerState.includes("pausar");
2070
+ const playerLooksActive = playerStateLooksActive(after.playerState || "");
1695
2071
  const playerLooksPaused = !playerLooksActive && /play|tocar|reproduzir|continuar|retomar|resume/.test(afterPlayerState);
1696
2072
  const wantsNext = descriptionWantsNext(targetDescription);
1697
2073
  const wantsPrevious = descriptionWantsPrevious(targetDescription);
1698
2074
  const wantsPause = descriptionWantsPause(targetDescription);
1699
2075
  const wantsResume = descriptionWantsResume(targetDescription);
2076
+ const wantsSpotifyTrackPageOpen = descriptionWantsSpotifyTrackPageOpen(targetDescription);
2077
+ const wantsSpotifyTrackPagePlay = descriptionWantsSpotifyTrackPagePlay(targetDescription);
1700
2078
  const mediaQueryTokens = extractMediaQueryTokens(targetDescription);
1701
2079
  const mediaMatchCount = countMatchingTokens(after.playerTitle || "", mediaQueryTokens);
1702
2080
  if (afterUrl.includes("music.youtube.com")) {
@@ -1722,6 +2100,51 @@ end tell
1722
2100
  return true;
1723
2101
  }
1724
2102
  }
2103
+ if (afterUrl.includes("open.spotify.com")) {
2104
+ if (wantsSpotifyTrackPageOpen && spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription)) {
2105
+ return true;
2106
+ }
2107
+ if (wantsSpotifyTrackPagePlay && spotifyTrackAlreadyPlayingMatchesDescription(after, targetDescription)) {
2108
+ return true;
2109
+ }
2110
+ if (wantsSpotifyTrackPageOpen) {
2111
+ const beforeWasTrackPage = beforeUrl.includes("/track/");
2112
+ const afterIsTrackPage = afterUrl.includes("/track/");
2113
+ if (afterIsTrackPage && (!beforeWasTrackPage || beforeUrl !== afterUrl)) {
2114
+ return true;
2115
+ }
2116
+ return spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription);
2117
+ }
2118
+ if (wantsPause && beforePlayerState && beforePlayerState !== afterPlayerState && playerLooksPaused) {
2119
+ return true;
2120
+ }
2121
+ if (wantsResume && playerLooksActive && beforePlayerState !== afterPlayerState) {
2122
+ return true;
2123
+ }
2124
+ if ((wantsNext || wantsPrevious) && beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle) {
2125
+ return true;
2126
+ }
2127
+ if (mediaQueryTokens.length >= 2) {
2128
+ const requiredMatches = Math.max(2, Math.ceil(mediaQueryTokens.length * 0.5));
2129
+ const titleLooksCorrect = mediaMatchCount >= requiredMatches;
2130
+ const playerChanged = ((beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle)
2131
+ || (!beforePlayerTitle && !!afterPlayerTitle)
2132
+ || (beforePlayerState && afterPlayerState && beforePlayerState !== afterPlayerState));
2133
+ if (titleLooksCorrect && (playerChanged || (beforePlayerTitle === afterPlayerTitle && playerLooksActive))) {
2134
+ return true;
2135
+ }
2136
+ }
2137
+ if (beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle && playerLooksActive) {
2138
+ return true;
2139
+ }
2140
+ if (!beforePlayerTitle && afterPlayerTitle && playerLooksActive) {
2141
+ return true;
2142
+ }
2143
+ }
2144
+ const looksLikeMediaAction = wantsNext || wantsPrevious || wantsPause || wantsResume || mediaQueryTokens.length > 0;
2145
+ if (looksLikeMediaAction && (afterUrl.includes("music.youtube.com") || afterUrl.includes("open.spotify.com"))) {
2146
+ return false;
2147
+ }
1725
2148
  const beforeTitle = normalizeText(before?.title || "");
1726
2149
  const afterTitle = normalizeText(after.title || "");
1727
2150
  if (beforeTitle && afterTitle && beforeTitle !== afterTitle) {
@@ -1742,9 +2165,11 @@ end tell
1742
2165
  afterState: null,
1743
2166
  };
1744
2167
  }
2168
+ let lastAfterState = null;
1745
2169
  for (let attempt = 0; attempt < 4; attempt += 1) {
1746
2170
  await delay(attempt === 0 ? 900 : 700);
1747
2171
  const afterState = await this.captureBrowserPageState(app).catch(() => null);
2172
+ lastAfterState = afterState;
1748
2173
  if (this.didBrowserPageStateChange(before, afterState, targetDescription, matchedHref)) {
1749
2174
  return {
1750
2175
  ok: true,
@@ -1756,7 +2181,7 @@ end tell
1756
2181
  return {
1757
2182
  ok: false,
1758
2183
  reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
1759
- afterState: null,
2184
+ afterState: lastAfterState,
1760
2185
  };
1761
2186
  }
1762
2187
  async pressShortcut(shortcut) {
@@ -1778,8 +2203,28 @@ end tell
1778
2203
  };
1779
2204
  const mediaCommand = mediaCommandMap[normalizedShortcut];
1780
2205
  if (mediaCommand) {
2206
+ if (normalizedShortcut === "media_pause") {
2207
+ const playbackState = await this.readGlobalMediaPlaybackState();
2208
+ if (playbackState !== "playing") {
2209
+ return {
2210
+ performed: false,
2211
+ reason: playbackState === "unknown"
2212
+ ? "O macOS nao confirmou nenhuma mídia tocando com segurança."
2213
+ : "Nenhuma mídia estava tocando no macOS.",
2214
+ };
2215
+ }
2216
+ }
2217
+ if (normalizedShortcut === "media_resume" || normalizedShortcut === "media_play") {
2218
+ const playbackState = await this.readGlobalMediaPlaybackState();
2219
+ if (playbackState === "playing") {
2220
+ return {
2221
+ performed: false,
2222
+ reason: "Já havia mídia tocando no macOS.",
2223
+ };
2224
+ }
2225
+ }
1781
2226
  await this.triggerMacOSMediaTransport(mediaCommand);
1782
- return;
2227
+ return { performed: true };
1783
2228
  }
1784
2229
  const namedKeyCodes = {
1785
2230
  return: 36,
@@ -1799,12 +2244,67 @@ end tell
1799
2244
  "-e",
1800
2245
  `tell application "System Events" to key code ${namedKeyCodes[key]}${usingClause}`,
1801
2246
  ]);
1802
- return;
2247
+ return { performed: true };
1803
2248
  }
1804
2249
  await this.runCommand("osascript", [
1805
2250
  "-e",
1806
2251
  `tell application "System Events" to keystroke "${escapeAppleScript(key)}"${usingClause}`,
1807
2252
  ]);
2253
+ return { performed: true };
2254
+ }
2255
+ async readGlobalMediaPlaybackState() {
2256
+ const swiftScript = `
2257
+ import Foundation
2258
+ import Dispatch
2259
+ import Darwin
2260
+
2261
+ typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
2262
+
2263
+ guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else {
2264
+ print("unknown")
2265
+ exit(0)
2266
+ }
2267
+
2268
+ guard let symbol = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
2269
+ print("unknown")
2270
+ exit(0)
2271
+ }
2272
+
2273
+ let fn = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
2274
+ let semaphore = DispatchSemaphore(value: 0)
2275
+ var isPlaying: Bool? = nil
2276
+
2277
+ fn(DispatchQueue.global()) { value in
2278
+ isPlaying = value
2279
+ semaphore.signal()
2280
+ }
2281
+
2282
+ if semaphore.wait(timeout: .now() + .milliseconds(800)) == .timedOut {
2283
+ print("unknown")
2284
+ exit(0)
2285
+ }
2286
+
2287
+ print(isPlaying == true ? "playing" : "not_playing")
2288
+ `;
2289
+ try {
2290
+ const { stdout } = await this.runCommandCapture("swift", [
2291
+ "-module-cache-path",
2292
+ path.join(os.tmpdir(), "otto-bridge-swift-modcache"),
2293
+ "-e",
2294
+ swiftScript,
2295
+ ]);
2296
+ const state = normalizeText(stdout || "").trim();
2297
+ if (state.includes("not_playing")) {
2298
+ return "not_playing";
2299
+ }
2300
+ if (state.includes("playing")) {
2301
+ return "playing";
2302
+ }
2303
+ return "unknown";
2304
+ }
2305
+ catch {
2306
+ return "unknown";
2307
+ }
1808
2308
  }
1809
2309
  async triggerMacOSMediaTransport(command) {
1810
2310
  const keyTypeMap = {
@@ -2889,6 +3389,14 @@ const wantsNext = /\\b(proxim[ao]?|next|skip|pular|avanca|avancar)\\b/.test(norm
2889
3389
  const wantsPrevious = /\\b(anterior|previous|volta[ar]?|back|retorna[ar]?)\\b/.test(normalizedDescription);
2890
3390
  const wantsPause = /\\b(pausa|pause|pausar)\\b/.test(normalizedDescription);
2891
3391
  const wantsResume = /\\b(retoma|retomar|resume|continu[ae]r|despausa|play)\\b/.test(normalizedDescription);
3392
+ const wantsSpotifyTrackPageOpen = location.hostname.includes("open.spotify.com")
3393
+ && /\\bspotify\\b/.test(normalizedDescription)
3394
+ && /\\b(pagina|titulo|nome|link)\\b/.test(normalizedDescription)
3395
+ && /\\b(faixa|musica|track)\\b/.test(normalizedDescription);
3396
+ const wantsSpotifyTrackPagePlay = location.hostname.includes("open.spotify.com")
3397
+ && /\\bspotify\\b/.test(normalizedDescription)
3398
+ && /\\b(play|reproduzir|tocar|verde|principal)\\b/.test(normalizedDescription)
3399
+ && /\\b(pagina|faixa|musica|track)\\b/.test(normalizedDescription);
2892
3400
  const stopWords = new Set([
2893
3401
  "o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos",
2894
3402
  "em", "no", "na", "nos", "nas", "para", "por", "com", "que", "visivel", "visiveis",
@@ -2998,6 +3506,33 @@ function clickElement(element, strategy, matchedText, matchedHref, score, totalC
2998
3506
  };
2999
3507
  }
3000
3508
 
3509
+ function pickYouTubeMusicPrimaryCard(element) {
3510
+ let current = element instanceof Element ? element : null;
3511
+ let best = null;
3512
+ let bestScore = -Infinity;
3513
+ while (current && current instanceof HTMLElement && current !== document.body) {
3514
+ const rect = current.getBoundingClientRect();
3515
+ const text = normalize(deriveText(current));
3516
+ let score = 0;
3517
+ if (rect.width >= window.innerWidth * 0.32) score += 40;
3518
+ if (rect.width >= window.innerWidth * 0.42) score += 80;
3519
+ if (rect.left <= window.innerWidth * 0.18) score += 90;
3520
+ if ((rect.left + rect.width) <= window.innerWidth * 0.68) score += 120;
3521
+ if (rect.top <= window.innerHeight * 0.7) score += 40;
3522
+ if (text.includes("mais do youtube")) score -= 260;
3523
+ if (text.includes("playlist")) score -= 40;
3524
+ if (text.includes("podcast")) score -= 40;
3525
+ if (text.includes("musica")) score += 28;
3526
+ if (text.includes("video")) score -= 18;
3527
+ if (score > bestScore) {
3528
+ bestScore = score;
3529
+ best = current;
3530
+ }
3531
+ current = current.parentElement;
3532
+ }
3533
+ return best;
3534
+ }
3535
+
3001
3536
  function attemptYouTubeMusicTransportClick() {
3002
3537
  if (!isYouTubeMusic || !(wantsNext || wantsPrevious || wantsPause || wantsResume)) {
3003
3538
  return null;
@@ -3045,6 +3580,97 @@ function attemptYouTubeMusicSearchResultClick() {
3045
3580
  return null;
3046
3581
  }
3047
3582
 
3583
+ const primaryCardButtons = Array.from(document.querySelectorAll("button, [role='button'], a[href*='watch?v=']"))
3584
+ .filter((node) => node instanceof HTMLElement || node instanceof HTMLAnchorElement)
3585
+ .filter((node) => isVisible(node))
3586
+ .filter((node) => !(node.closest("ytmusic-player-bar")))
3587
+ .map((node) => {
3588
+ const element = node;
3589
+ const rect = element.getBoundingClientRect();
3590
+ const primaryCard = pickYouTubeMusicPrimaryCard(element);
3591
+ const primaryCardRect = primaryCard ? primaryCard.getBoundingClientRect() : rect;
3592
+ const label = normalize([
3593
+ deriveText(element),
3594
+ element.getAttribute("aria-label"),
3595
+ element.getAttribute("title"),
3596
+ ].filter(Boolean).join(" "));
3597
+ const cardText = normalize(primaryCard ? deriveText(primaryCard) : deriveText(element.parentElement || element));
3598
+ const isPlayButton = /\b(play|reproduzir|tocar)\b/.test(label);
3599
+ const buttonCenterX = rect.left + (rect.width / 2);
3600
+ const cardCenterX = primaryCardRect.left + (primaryCardRect.width / 2);
3601
+ let score = 0;
3602
+
3603
+ if (!isPlayButton) {
3604
+ return null;
3605
+ }
3606
+ if (buttonCenterX > (window.innerWidth * 0.44)) {
3607
+ return null;
3608
+ }
3609
+ if (rect.width < 110 || rect.height < 36) {
3610
+ return null;
3611
+ }
3612
+ if (primaryCardRect.left > (window.innerWidth * 0.16)) {
3613
+ return null;
3614
+ }
3615
+ if (cardCenterX > (window.innerWidth * 0.36)) {
3616
+ return null;
3617
+ }
3618
+ if (primaryCardRect.width < (window.innerWidth * 0.28)) {
3619
+ return null;
3620
+ }
3621
+
3622
+ for (const phrase of quotedPhrases) {
3623
+ if (!phrase) continue;
3624
+ if (cardText.includes(phrase) || label.includes(phrase)) score += 220;
3625
+ }
3626
+
3627
+ for (const token of tokens) {
3628
+ if (cardText.includes(token)) score += 30;
3629
+ else if (label.includes(token)) score += 16;
3630
+ }
3631
+
3632
+ if (tokens.length > 1) {
3633
+ const matchCount = tokens.filter((token) => cardText.includes(token)).length;
3634
+ if (matchCount >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 120;
3635
+ if (matchCount === tokens.length) score += 80;
3636
+ }
3637
+
3638
+ if ((primaryCardRect.left + (primaryCardRect.width / 2)) < (window.innerWidth * 0.48)) score += 320;
3639
+ if (primaryCardRect.left <= window.innerWidth * 0.18) score += 140;
3640
+ if (primaryCardRect.width >= window.innerWidth * 0.38) score += 120;
3641
+ if ((primaryCardRect.left + primaryCardRect.width) <= window.innerWidth * 0.7) score += 160;
3642
+ if (buttonCenterX < (window.innerWidth * 0.34)) score += 220;
3643
+ if (buttonCenterX < (window.innerWidth * 0.4)) score += 120;
3644
+ if (rect.top < (window.innerHeight * 0.7)) score += 90;
3645
+ if (rect.width >= 120) score += 36;
3646
+ if (rect.height >= 44) score += 18;
3647
+ if (cardText.includes("mais do youtube")) score -= 320;
3648
+ if (primaryCardRect.left > window.innerWidth * 0.45) score -= 260;
3649
+ if (buttonCenterX > window.innerWidth * 0.42) score -= 360;
3650
+ score += Math.max(0, 40 - Math.round(rect.top / 20));
3651
+
3652
+ return score > 0 ? {
3653
+ node: element,
3654
+ score,
3655
+ label,
3656
+ matchedText: primaryCard ? deriveText(primaryCard) : deriveText(element),
3657
+ } : null;
3658
+ })
3659
+ .filter(Boolean)
3660
+ .sort((left, right) => right.score - left.score);
3661
+
3662
+ if (primaryCardButtons.length) {
3663
+ const winner = primaryCardButtons[0];
3664
+ return clickElement(
3665
+ winner.node,
3666
+ "safari_dom_ytmusic_primary_card",
3667
+ winner.matchedText || winner.label,
3668
+ "",
3669
+ winner.score,
3670
+ primaryCardButtons.length,
3671
+ );
3672
+ }
3673
+
3048
3674
  const rows = Array.from(document.querySelectorAll("ytmusic-responsive-list-item-renderer"))
3049
3675
  .filter((node) => node instanceof HTMLElement)
3050
3676
  .filter((node) => isVisible(node));
@@ -3137,6 +3763,215 @@ if (ytmResult) {
3137
3763
  return ytmResult;
3138
3764
  }
3139
3765
 
3766
+ function attemptSpotifyTrackPagePlayClick() {
3767
+ if (!location.hostname.includes("open.spotify.com")) {
3768
+ return null;
3769
+ }
3770
+ if (!location.pathname.includes("/track/")) {
3771
+ return null;
3772
+ }
3773
+
3774
+ const rankedButtons = Array.from(document.querySelectorAll(
3775
+ "main [data-testid='play-button'], main button[aria-label], main button[title], [data-testid='entityHeader'] button, main button"
3776
+ ))
3777
+ .filter((node) => node instanceof HTMLElement)
3778
+ .filter((node) => isVisible(node))
3779
+ .filter((node) => !(node.closest("footer")))
3780
+ .map((node, index) => {
3781
+ const rect = node.getBoundingClientRect();
3782
+ const label = normalize([
3783
+ deriveText(node),
3784
+ node.getAttribute("aria-label"),
3785
+ node.getAttribute("title"),
3786
+ node.getAttribute("data-testid"),
3787
+ ].filter(Boolean).join(" "));
3788
+ const background = normalize(window.getComputedStyle(node).backgroundColor || "");
3789
+ let score = 0;
3790
+
3791
+ if (!/play|tocar|reproduzir/.test(label)) {
3792
+ return null;
3793
+ }
3794
+
3795
+ if (node.getAttribute("data-testid") === "play-button") score += 220;
3796
+ if (label.includes("play")) score += 100;
3797
+ if (background.includes("29, 185, 84") || background.includes("30, 215, 96")) score += 180;
3798
+ if (rect.top <= window.innerHeight * 0.72) score += 90;
3799
+ if (rect.top <= window.innerHeight * 0.56) score += 70;
3800
+ if (rect.left <= window.innerWidth * 0.4) score += 80;
3801
+ if (rect.width >= 44) score += 30;
3802
+ if (rect.height >= 44) score += 30;
3803
+ if (node.closest("[data-testid='entityHeader'], main")) score += 60;
3804
+ score += Math.max(0, 18 - index);
3805
+
3806
+ return score > 0 ? { node, label, score } : null;
3807
+ })
3808
+ .filter(Boolean)
3809
+ .sort((left, right) => right.score - left.score);
3810
+
3811
+ if (!rankedButtons.length) {
3812
+ return null;
3813
+ }
3814
+
3815
+ const winner = rankedButtons[0];
3816
+ return clickElement(
3817
+ winner.node,
3818
+ "safari_dom_spotify_track_page_play",
3819
+ winner.label,
3820
+ location.href || "",
3821
+ winner.score,
3822
+ rankedButtons.length,
3823
+ );
3824
+ }
3825
+
3826
+ const spotifyTrackPagePlay = attemptSpotifyTrackPagePlayClick();
3827
+ if (spotifyTrackPagePlay) {
3828
+ return spotifyTrackPagePlay;
3829
+ }
3830
+
3831
+ function attemptSpotifySearchResultClick() {
3832
+ if (!location.hostname.includes("open.spotify.com")) {
3833
+ return null;
3834
+ }
3835
+ if (!quotedPhrases.length && !tokens.length) {
3836
+ return null;
3837
+ }
3838
+
3839
+ const rows = Array.from(document.querySelectorAll(
3840
+ "[data-testid='tracklist-row'], [role='row']"
3841
+ ))
3842
+ .filter((node) => node instanceof HTMLElement)
3843
+ .filter((node) => isVisible(node));
3844
+
3845
+ const rankedRows = rows
3846
+ .map((row, index) => {
3847
+ const titleNode = row.querySelector(
3848
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3849
+ );
3850
+ const navigationTarget = titleNode instanceof HTMLAnchorElement || titleNode instanceof HTMLElement
3851
+ ? titleNode
3852
+ : null;
3853
+ const titleText = String((titleNode && titleNode.textContent) || "").trim();
3854
+ const rowText = deriveText(row);
3855
+ const normalizedTitle = normalize(titleText);
3856
+ const normalizedRow = normalize(rowText);
3857
+ let score = 0;
3858
+
3859
+ for (const phrase of quotedPhrases) {
3860
+ if (!phrase) continue;
3861
+ if (normalizedTitle.includes(phrase)) score += 180;
3862
+ else if (normalizedRow.includes(phrase)) score += 110;
3863
+ }
3864
+
3865
+ for (const token of tokens) {
3866
+ if (normalizedTitle.includes(token)) score += 26;
3867
+ else if (normalizedRow.includes(token)) score += 10;
3868
+ }
3869
+
3870
+ const titleMatches = tokens.filter((token) => normalizedTitle.includes(token)).length;
3871
+ if (tokens.length > 1 && titleMatches >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 90;
3872
+ if (titleMatches === tokens.length && tokens.length > 0) score += 70;
3873
+ if (index < 4) score += Math.max(0, 24 - (index * 5));
3874
+
3875
+ const playCandidate = Array.from(row.querySelectorAll("button, [role='button'], a[href*='/track/']"))
3876
+ .filter((candidate) => candidate instanceof HTMLElement || candidate instanceof HTMLAnchorElement)
3877
+ .filter((candidate) => isVisible(candidate))
3878
+ .map((candidate) => {
3879
+ const label = normalize([
3880
+ deriveText(candidate),
3881
+ candidate.getAttribute("aria-label"),
3882
+ candidate.getAttribute("title"),
3883
+ ].filter(Boolean).join(" "));
3884
+ let candidateScore = 0;
3885
+ if (/play|tocar|reproduzir/.test(label)) candidateScore += 40;
3886
+ if (candidate instanceof HTMLAnchorElement && normalize(candidate.href).includes("/track/")) candidateScore += 18;
3887
+ return { candidate, candidateScore };
3888
+ })
3889
+ .sort((left, right) => right.candidateScore - left.candidateScore);
3890
+
3891
+ if (wantsSpotifyTrackPageOpen && !navigationTarget) {
3892
+ return null;
3893
+ }
3894
+
3895
+ return score > 0 ? {
3896
+ row,
3897
+ titleText,
3898
+ href: titleNode instanceof HTMLAnchorElement ? titleNode.href : "",
3899
+ score: score + (playCandidate[0]?.candidateScore || 0),
3900
+ target: wantsSpotifyTrackPageOpen
3901
+ ? navigationTarget
3902
+ : (playCandidate[0]?.candidate || navigationTarget || row),
3903
+ } : null;
3904
+ })
3905
+ .filter(Boolean)
3906
+ .sort((left, right) => right.score - left.score);
3907
+
3908
+ if (!rankedRows.length) {
3909
+ return null;
3910
+ }
3911
+
3912
+ const winner = rankedRows[0];
3913
+ const spotifyRow = winner.row instanceof HTMLElement ? winner.row : null;
3914
+ const titleCandidate = spotifyRow?.querySelector(
3915
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3916
+ );
3917
+ const explicitPlayButton = spotifyRow
3918
+ ? Array.from(spotifyRow.querySelectorAll("button, [role='button']"))
3919
+ .filter((node) => node instanceof HTMLElement)
3920
+ .filter((node) => isVisible(node))
3921
+ .find((node) => {
3922
+ const label = normalize([
3923
+ deriveText(node),
3924
+ node.getAttribute("aria-label"),
3925
+ node.getAttribute("title"),
3926
+ ].filter(Boolean).join(" "));
3927
+ return /\b(play|tocar|reproduzir)\b/.test(label);
3928
+ })
3929
+ : null;
3930
+
3931
+ const clickTarget = wantsSpotifyTrackPageOpen
3932
+ ? (titleCandidate || winner.target)
3933
+ : (explicitPlayButton || titleCandidate || winner.target);
3934
+ const clicked = clickElement(
3935
+ clickTarget,
3936
+ wantsSpotifyTrackPageOpen
3937
+ ? "safari_dom_spotify_track_link"
3938
+ : (explicitPlayButton ? "safari_dom_spotify_play_button" : "safari_dom_spotify_result"),
3939
+ winner.titleText || deriveText(winner.row),
3940
+ winner.href || "",
3941
+ winner.score,
3942
+ rankedRows.length,
3943
+ );
3944
+ if (!clicked) {
3945
+ return null;
3946
+ }
3947
+ if (spotifyRow instanceof HTMLElement && !explicitPlayButton && !wantsSpotifyTrackPageOpen) {
3948
+ const rect = spotifyRow.getBoundingClientRect();
3949
+ spotifyRow.dispatchEvent(new MouseEvent("dblclick", {
3950
+ bubbles: true,
3951
+ cancelable: true,
3952
+ view: window,
3953
+ clientX: rect.left + (rect.width / 2),
3954
+ clientY: rect.top + (rect.height / 2),
3955
+ }));
3956
+ }
3957
+ return clicked;
3958
+ }
3959
+
3960
+ const spotifyResult = attemptSpotifySearchResultClick();
3961
+ if (spotifyResult) {
3962
+ return spotifyResult;
3963
+ }
3964
+
3965
+ if (location.hostname.includes("open.spotify.com") && (wantsSpotifyTrackPageOpen || wantsSpotifyTrackPagePlay)) {
3966
+ return {
3967
+ clicked: false,
3968
+ reason: wantsSpotifyTrackPageOpen
3969
+ ? "Nenhuma faixa visivel do Spotify Web combinou com a busca atual; nao vou clicar em abas, playlists ou albuns por fallback."
3970
+ : "O Spotify Web nao mostrou um botao principal de reproduzir confiavel; nao vou clicar em elementos genericos por fallback.",
3971
+ strategy: "safari_dom_spotify_no_fallback",
3972
+ };
3973
+ }
3974
+
3140
3975
  function scoreCandidate(element, rank) {
3141
3976
  const text = deriveText(element);
3142
3977
  const href = element instanceof HTMLAnchorElement
@@ -3257,7 +4092,7 @@ tell application "Safari"
3257
4092
  activate
3258
4093
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
3259
4094
  delay 1
3260
- set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';return JSON.stringify({title,url,text,playerTitle,playerState});})();"
4095
+ set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const isYouTubeMusic=location.hostname.includes('music.youtube.com');const isSpotify=location.hostname.includes('open.spotify.com');let playerButton=null;let playerTitle='';let playerState='';if(isYouTubeMusic){playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';}else if(isSpotify){const visible=(node)=>{if(!(node instanceof Element))return false;const rect=node.getBoundingClientRect();if(rect.width<4||rect.height<4)return false;const style=window.getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none'||Number(style.opacity||'1')===0)return false;return rect.bottom>=0&&rect.right>=0&&rect.top<=window.innerHeight&&rect.left<=window.innerWidth;};const spotifyTitleCandidates=Array.from(document.querySelectorAll(\"[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']\")).filter((node)=>visible(node)).map((node)=>({node,text:((node&&node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>entry.text).sort((left,right)=>{const leftBottomBias=(left.rect.top>=window.innerHeight*0.72?200:0)+(left.rect.left<=window.innerWidth*0.45?120:0)+left.rect.top;const rightBottomBias=(right.rect.top>=window.innerHeight*0.72?200:0)+(right.rect.left<=window.innerWidth*0.45?120:0)+right.rect.top;return rightBottomBias-leftBottomBias;});playerTitle=(spotifyTitleCandidates[0]&&spotifyTitleCandidates[0].text)||'';playerButton=Array.from(document.querySelectorAll(\"footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]\")).filter((node)=>visible(node)).map((node)=>({node,label:((node.getAttribute('aria-label')||node.getAttribute('title')||node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>/play|pause|tocar|pausar|reproduzir/i.test(entry.label)).sort((left,right)=>{const leftScore=(left.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((left.rect.left+left.rect.width/2)-(window.innerWidth/2)));const rightScore=(right.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((right.rect.left+right.rect.width/2)-(window.innerWidth/2)));return rightScore-leftScore;})[0]?.node||null;playerState=(playerButton&&((playerButton.getAttribute('aria-label')||playerButton.getAttribute('title')||playerButton.textContent)||'').trim())||'';}return JSON.stringify({title,url,text,playerTitle,playerState});})();"
3261
4096
  set pageJson to do JavaScript jsCode in current tab of front window
3262
4097
  end tell
3263
4098
  return pageJson