diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 763e854b82..c58bbd789c 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -909,6 +909,19 @@ } }, "tooltip": "Convert Chinese character to Traditional or Simplified" + }, + "auto-skip-languages": { + "label": "Auto-skip languages", + "tooltip": "Automatically skip songs when these languages are detected in lyrics (comma-separated language codes)", + "prompt": { + "title": "Auto-skip languages", + "label": "Enter language codes (comma-separated, e.g., ja,ko,zh):", + "placeholder": "e.g., ja,ko,zh" + } + }, + "auto-dislike-skipped-languages": { + "label": "Auto-dislike skipped songs", + "tooltip": "Automatically dislike songs before skipping them when a language from the skip list is detected" } }, "name": "Synced Lyrics", @@ -916,6 +929,9 @@ "fetching": "Fetching...", "normal": "Refetch lyrics" }, + "toast": { + "auto-skip": "Auto-skipping song with detected language: {{language}}" + }, "warnings": { "duration-mismatch": "⚠️ - The lyrics may be out of sync due to a duration mismatch.", "inexact": "⚠️ - The lyrics for this song may not be exact", diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index 533f05bfb1..cfd352f784 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -22,6 +22,8 @@ export default createPlugin({ defaultTextString: '♪', lineEffect: 'fancy', romanization: true, + autoSkipLanguages: '', + autoDislikeSkippedLanguages: false, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, menu, diff --git a/src/plugins/synced-lyrics/menu.ts b/src/plugins/synced-lyrics/menu.ts index 9f4fcc6d93..8f87f530df 100644 --- a/src/plugins/synced-lyrics/menu.ts +++ b/src/plugins/synced-lyrics/menu.ts @@ -1,5 +1,9 @@ +import prompt from 'custom-electron-prompt'; + import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; + import { providerNames } from './providers'; import type { MenuItemConstructorOptions } from 'electron'; @@ -233,5 +237,51 @@ export const menu = async ( }); }, }, + { + label: t('plugins.synced-lyrics.menu.auto-skip-languages.label'), + toolTip: t('plugins.synced-lyrics.menu.auto-skip-languages.tooltip'), + async click() { + const languages = + (await prompt( + { + title: t( + 'plugins.synced-lyrics.menu.auto-skip-languages.prompt.title', + ), + label: t( + 'plugins.synced-lyrics.menu.auto-skip-languages.prompt.label', + ), + value: config.autoSkipLanguages || '', + type: 'input', + inputAttrs: { + type: 'text', + placeholder: t( + 'plugins.synced-lyrics.menu.auto-skip-languages.prompt.placeholder', + ), + }, + ...promptOptions(), + }, + ctx.window, + )) ?? config.autoSkipLanguages; + + ctx.setConfig({ + autoSkipLanguages: languages, + }); + }, + }, + { + label: t( + 'plugins.synced-lyrics.menu.auto-dislike-skipped-languages.label', + ), + toolTip: t( + 'plugins.synced-lyrics.menu.auto-dislike-skipped-languages.tooltip', + ), + type: 'checkbox', + checked: config.autoDislikeSkippedLanguages, + click(item) { + ctx.setConfig({ + autoDislikeSkippedLanguages: item.checked, + }); + }, + }, ]; }; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 3e18626bed..19f39cb209 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -260,10 +260,17 @@ export const LyricsPicker = (props: { /> - +
+ + + + {currentLyrics().data?.language?.toUpperCase()} + + +
{ } }); +// Auto-skip songs based on detected language +let skippedVideoId: string | null = null; +let skipTimer: ReturnType | null = null; +createEffect(() => { + const cfg = config(); + const lyrics = bestLanguageResult(); + + if (!cfg?.enabled || !cfg.autoSkipLanguages || !lyrics?.data?.language) { + // lyrics is null while providers are fetching (i.e. a new song just started). + // Also reset guards when auto-skip is disabled or configured languages are cleared. + if (!lyrics || !cfg?.enabled || !cfg.autoSkipLanguages) { + skippedVideoId = null; + } + if (skipTimer !== null) { + clearTimeout(skipTimer); + skipTimer = null; + } + return; + } + + const skipLanguages = cfg.autoSkipLanguages + .split(',') + .map((lang) => lang.trim().toLowerCase()) + .filter((lang) => lang.length > 0); + + if (skipLanguages.length === 0) return; + + const detectedLanguage = lyrics.data.language.toLowerCase(); + + if (skipLanguages.includes(detectedLanguage)) { + const videoId = getSongInfo().videoId; + if (videoId === skippedVideoId) return; + skippedVideoId = videoId; + + const appApi = document.querySelector('ytmusic-app'); + + // Show toast notification + appApi?.toastService?.show( + t('plugins.synced-lyrics.toast.auto-skip', { + language: lyrics.data.language.toUpperCase(), + }), + ); + + // Optionally dislike the song + if (cfg.autoDislikeSkippedLanguages) { + const dislikeButton = document.querySelector( + '#button-shape-dislike > button[aria-pressed="false"]', + ); + if (dislikeButton) { + dislikeButton.click(); + } + } + + // Skip to next song — timer lives outside the effect so re-runs don't cancel it. + // Capture videoId so the callback can verify the track hasn't changed by the time it fires. + const scheduledFor = videoId; + if (skipTimer !== null) clearTimeout(skipTimer); + skipTimer = setTimeout(() => { + skipTimer = null; + if (getSongInfo().videoId === scheduledFor) { + appApi?.playerApi?.nextVideo(); + } + }, 500); + } +}); + type LyricsRendererChild = | { kind: 'LyricsPicker' } | { kind: 'LoadingKaomoji' } diff --git a/src/plugins/synced-lyrics/renderer/store.ts b/src/plugins/synced-lyrics/renderer/store.ts index 8d9df88bc7..36eeb7cbd1 100644 --- a/src/plugins/synced-lyrics/renderer/store.ts +++ b/src/plugins/synced-lyrics/renderer/store.ts @@ -1,5 +1,6 @@ import { createStore } from 'solid-js/store'; import { createMemo } from 'solid-js'; +import { detect } from 'tinyld'; import { getSongInfo } from '@/providers/song-info-front'; @@ -10,7 +11,7 @@ import { } from '../providers'; import { providers } from '../providers/renderer'; -import type { LyricProvider } from '../types'; +import type { LyricProvider, LyricResult } from '../types'; import type { SongInfo } from '@/providers/song-info'; type LyricsStore = { @@ -41,6 +42,24 @@ export const currentLyrics = createMemo(() => { return lyricsStore.lyrics[provider]; }); +/** + * Returns the best available provider result that has a detected language, + * without requiring lyricsStore.provider to have been set by LyricsPicker. + * Prefers the currently selected provider, then falls back to any done provider + * with a language so that auto-skip works even when the lyrics panel is hidden. + */ +export const bestLanguageResult = createMemo(() => { + const current = lyricsStore.lyrics[lyricsStore.provider]; + if (current.state === 'done' && current.data?.language) return current; + + for (const name of providerNames) { + const state = lyricsStore.lyrics[name]; + if (state.state === 'done' && state.data?.language) return state; + } + + return null; +}); + type VideoId = string; type SearchCacheData = Record; @@ -51,6 +70,44 @@ interface SearchCache { // TODO: Maybe use localStorage for the cache. const searchCache = new Map(); + +/** + * Detects the language of lyrics and adds it to the result. + * Handles edge cases: no lyrics, empty text, detection failure. + */ +const detectLyricsLanguage = ( + result: LyricResult | null, +): LyricResult | null => { + if (!result) return null; + + try { + // Extract text from either plain lyrics or synced lines + let textToAnalyze = ''; + + if (result.lyrics) { + textToAnalyze = result.lyrics.trim(); + } else if (result.lines && result.lines.length > 0) { + textToAnalyze = result.lines + .map((line) => line.text) + .join('\n') + .trim(); + } + + // Only attempt detection if we have meaningful text + if (textToAnalyze.length > 0) { + const detectedLang = detect(textToAnalyze); + // Only set language if detection was successful (not empty string) + if (detectedLang) { + return { ...result, language: detectedLang }; + } + } + } catch (error) { + // Detection failed - log but don't throw, just leave language undefined + console.warn('Language detection failed:', error); + } + + return result; +}; export const fetchLyrics = (info: SongInfo) => { if (searchCache.has(info.videoId)) { const cache = searchCache.get(info.videoId)!; @@ -100,8 +157,11 @@ export const fetchLyrics = (info: SongInfo) => { provider .search(info) .then((res) => { + // Detect language from the lyrics result + const resultWithLanguage = detectLyricsLanguage(res); + pCache.state = 'done'; - pCache.data = res; + pCache.data = resultWithLanguage; if (getSongInfo().videoId === info.videoId) { setLyricsStore('lyrics', (old) => { @@ -109,7 +169,7 @@ export const fetchLyrics = (info: SongInfo) => { ...old, [providerName]: { state: 'done', - data: res ? { ...res } : null, + data: resultWithLanguage ? { ...resultWithLanguage } : null, error: null, }, }; @@ -157,10 +217,13 @@ export const retrySearch = (provider: ProviderName, info: SongInfo) => { providers[provider] .search(info) .then((res) => { + // Detect language from the lyrics result + const resultWithLanguage = detectLyricsLanguage(res); + setLyricsStore('lyrics', (old) => { return { ...old, - [provider]: { state: 'done', data: res, error: null }, + [provider]: { state: 'done', data: resultWithLanguage, error: null }, }; }); }) diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index 19154b4468..9efc7370d5 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -195,6 +195,13 @@ transition: transform 0.25s ease-in-out; } +.provider-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + .lyrics-picker-dot { display: inline-block; cursor: pointer; @@ -232,6 +239,17 @@ div:has(> .lyrics-picker) { } } +.language-badge { + display: inline-block; + padding: 2px 6px; + font-size: 0.7rem; + font-weight: 600; + color: var(--ytmusic-text-primary); + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + text-transform: uppercase; +} + /* Animations */ @keyframes lyrics-wobble { from { diff --git a/src/plugins/synced-lyrics/types.ts b/src/plugins/synced-lyrics/types.ts index a429364406..14b3aa8333 100644 --- a/src/plugins/synced-lyrics/types.ts +++ b/src/plugins/synced-lyrics/types.ts @@ -14,6 +14,8 @@ export type SyncedLyricsPluginConfig = { | 'simplifiedToTraditional' | 'traditionalToSimplified' | 'disabled'; + autoSkipLanguages: string; + autoDislikeSkippedLanguages: boolean; }; export type LineLyricsStatus = 'previous' | 'current' | 'upcoming'; @@ -35,6 +37,7 @@ export interface LyricResult { lyrics?: string; lines?: LineLyrics[]; + language?: string; } // prettier-ignore