diff --git a/websites/S/SoloLatino/iframe.ts b/websites/S/SoloLatino/iframe.ts new file mode 100644 index 000000000000..ff1246ea0462 --- /dev/null +++ b/websites/S/SoloLatino/iframe.ts @@ -0,0 +1,13 @@ +const iframe = new iFrame() + +iframe.on('UpdateData', async () => { + const video = document.querySelector('video') + + if (video && !Number.isNaN(video.duration)) { + iframe.send({ + duration: video.duration, + currentTime: video.currentTime, + paused: video.paused, + }) + } +}) diff --git a/websites/S/SoloLatino/metadata.json b/websites/S/SoloLatino/metadata.json new file mode 100644 index 000000000000..d8c5bfad043b --- /dev/null +++ b/websites/S/SoloLatino/metadata.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://schemas.premid.app/metadata/1.16", + "apiVersion": 1, + "author": { + "name": "0immagic0", + "id": "1545854937341239306" + }, + "contributors": [], + "service": "SoloLatino", + "description": { + "en": "SoloLatino.Net is a free streaming website where you can watch movies, series, animes and doramas dubbed in Latin Spanish.", + "es": "SoloLatino.Net es un sitio de streaming gratuito donde puedes ver películas, series, animes y doramas doblados al español latino." + }, + "url": "sololatino.net", + "regExp": "^https?[:][/][/]sololatino[.]net[/]", + "version": "1.0.0", + "logo": "https://i.imgur.com/WTlbzGb.png", + "thumbnail": "https://i.imgur.com/WTlbzGb.png", + "color": "#e50914", + "category": "videos", + "tags": [ + "streaming", + "peliculas", + "series", + "anime", + "doramas", + "latino", + "espanol", + "movies", + "tv" + ], + "iframe": true, + "iFrameRegExp": "player[.]pelisserieshoy[.]com|embed69[.]org|xupalace[.]org|drive[.]google[.]com|.*vidhide.*|.*streamhide.*", + "settings": [ + { + "id": "brand", + "title": "Mostrar SoloLatino en el estado", + "icon": "fas fa-signature", + "value": true, + "description": "Muestra \"Viendo SoloLatino\" como título de la actividad. Si lo desactivas, se muestra \"Viendo\" seguido únicamente del nombre de la serie o película." + }, + { + "id": "showTempEp", + "title": "Mostrar temporada y episodio", + "icon": "fas fa-list-ol", + "value": true, + "description": "Muestra la temporada y el episodio actual mientras ves una serie." + }, + { + "id": "showTime", + "title": "Mostrar tiempo restante", + "icon": "fas fa-stopwatch", + "value": true, + "description": "Muestra el minuto en el que vas, con un contador sincronizado al reproductor." + }, + { + "id": "showCover", + "title": "Mostrar portada", + "icon": "fas fa-image", + "value": true, + "description": "Usa la portada de la serie o película como imagen grande en lugar del logo." + }, + { + "id": "showPlayState", + "title": "Mostrar estado de reproducción", + "icon": "fas fa-play-circle", + "value": true, + "description": "Muestra un pequeño icono que indica si el video está en reproducción o en pausa." + }, + { + "id": "showButton", + "title": "Mostrar botón Ver ahora", + "icon": "fas fa-external-link-alt", + "value": true, + "description": "Añade un botón que abre directamente la página de la serie, episodio o película que estás viendo." + } + ] +} diff --git a/websites/S/SoloLatino/presence.ts b/websites/S/SoloLatino/presence.ts new file mode 100644 index 000000000000..27362dcbc250 --- /dev/null +++ b/websites/S/SoloLatino/presence.ts @@ -0,0 +1,258 @@ +import { ActivityType, Assets, getTimestamps } from 'premid' + +const presence = new Presence({ + // Crea tu aplicación en https://discord.com/developers/applications y pega aquí su Client ID + clientId: '1545854937341239306', +}) + +enum ActivityAssets { + Logo = 'https://i.imgur.com/WTlbzGb.png', +} + +interface VideoData { + duration: number + currentTime: number + paused: boolean +} + +interface SchemaNode { + '@type'?: string | string[] + 'name'?: string + 'image'?: string + 'partOfSeason'?: { seasonNumber?: number } + 'partOfSeries'?: { name?: string } +} + +let video: VideoData = { duration: 0, currentTime: 0, paused: true } +let lastIFrameUpdate = 0 +const iframeCacheDuration = 5000 +const browsingTimestamp = Math.floor(Date.now() / 1000) + +const strings = presence.getStrings({ + play: 'general.playing', + pause: 'general.paused', + browse: 'general.browsing', +}) + +presence.on('iFrameData', (data: unknown) => { + video = data as VideoData + lastIFrameUpdate = Date.now() +}) + +function getSchemaNode(): SchemaNode | null { + for (const script of document.querySelectorAll('script[type="application/ld+json"]')) { + try { + const parsed = JSON.parse(script.textContent ?? '') as SchemaNode & { '@graph'?: SchemaNode[] } + const graph = parsed['@graph'] + const nodes = Array.isArray(graph) ? graph : [parsed] + for (const node of nodes) { + const type = Array.isArray(node['@type']) ? node['@type'][0] : node['@type'] + if (type === 'TVEpisode' || type === 'Movie' || type === 'TVSeries') + return node + } + } + catch { + continue + } + } + return null +} + +function cleanTitle(raw: string): string { + return raw + .replace(/^\s*Ver\s+/i, '') + .replace(/\s*\(\d{4}\)\s*Online.*$/i, '') + .replace(/\s+Online.*$/i, '') + .replace(/\s*\|\s*SoloLatino\.Net\s*$/i, '') + .trim() +} + +function getOgTitle(): string { + return document.querySelector('meta[property="og:title"]')?.content ?? '' +} + +function getOgImage(): string { + return document.querySelector('meta[property="og:image"]')?.content ?? '' +} + +function getYear(): string { + return getOgTitle().match(/\((\d{4})\)/)?.[1] ?? '' +} + +function prettify(value: string): string { + return value + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) + return text + return `${text.slice(0, maxLength - 1).trimEnd()}…` +} + +function getParentVideo(): VideoData | null { + const el = document.querySelector('#player-frame video') + if (!el || Number.isNaN(el.duration) || el.duration <= 0 || Number.isNaN(el.currentTime)) + return null + return { duration: el.duration, currentTime: el.currentTime, paused: el.paused } +} + +function getVideoData(): VideoData | null { + if (Date.now() - lastIFrameUpdate < iframeCacheDuration && video.duration > 0) + return video + const parentVideo = getParentVideo() + return parentVideo || (video.duration > 0 ? video : null) +} + +function applyVideoState( + presenceData: PresenceData, + showTime: boolean, + showPlayState: boolean, + playLabel: string, + pauseLabel: string, +): void { + const currentVideo = getVideoData() + if (!currentVideo || currentVideo.duration <= 0 || Number.isNaN(currentVideo.duration) || Number.isNaN(currentVideo.currentTime)) + return + + if (showTime && !currentVideo.paused) { + [presenceData.startTimestamp, presenceData.endTimestamp] = getTimestamps( + Math.floor(currentVideo.currentTime), + Math.floor(currentVideo.duration), + ) + } + + if (showPlayState) { + presenceData.smallImageKey = currentVideo.paused ? Assets.Pause : Assets.Play + presenceData.smallImageText = currentVideo.paused ? pauseLabel : playLabel + } +} + +function applyButton(presenceData: PresenceData, showButton: boolean, url: string): void { + if (showButton) + presenceData.buttons = [{ label: 'Ver ahora', url }] +} + +presence.on('UpdateData', async () => { + const s = await strings + const brand = await presence.getSetting('brand') + const showTempEp = await presence.getSetting('showTempEp') + const showTime = await presence.getSetting('showTime') + const showCover = await presence.getSetting('showCover') + const showPlayState = await presence.getSetting('showPlayState') + const showButton = await presence.getSetting('showButton') + + const { pathname, href, search } = document.location + const schema = getSchemaNode() + + const presenceData: PresenceData = { + type: ActivityType.Watching, + name: 'SoloLatino', + largeImageKey: ActivityAssets.Logo, + } + + const episodeMatch = pathname.match(/^\/serie\/[^/]+\/temporada-(\d+)\/episodio-(\d+)/) + const seriesMatch = pathname.match(/^\/serie\/[^/]+\/?$/) + const movieMatch = pathname.match(/^\/pelicula\/[^/]+\/?$/) + + if (episodeMatch) { + const seasonNumber = episodeMatch[1] + const episodeNumber = episodeMatch[2] + const seriesName = schema?.partOfSeries?.name || cleanTitle(getOgTitle()) || 'Serie' + const episodeTitle = schema?.name || document.querySelector('h1')?.textContent?.trim() || '' + const cover = getOgImage() + const tempEp = `Temporada ${seasonNumber} · Episodio ${episodeNumber}` + + if (brand) { + presenceData.details = `Viendo ${seriesName}` + if (showTempEp) + presenceData.state = episodeTitle ? truncate(`${tempEp} · ${episodeTitle}`, 120) : tempEp + else if (episodeTitle) + presenceData.state = truncate(episodeTitle, 120) + } + else { + presenceData.name = seriesName + if (showTempEp) + presenceData.details = tempEp + if (episodeTitle) + presenceData.state = truncate(episodeTitle, 120) + } + + presenceData.largeImageKey = showCover && cover ? cover : ActivityAssets.Logo + presenceData.largeImageText = seriesName + applyVideoState(presenceData, showTime, showPlayState, s.play, s.pause) + applyButton(presenceData, showButton, href) + return presence.setActivity(presenceData) + } + + if (movieMatch) { + const movieName = schema?.name || cleanTitle(getOgTitle()) || 'Película' + const cover = getOgImage() + const year = getYear() + const movieLabel = year ? `Película · ${year}` : 'Película' + + if (brand) { + presenceData.details = `Viendo ${movieName}` + presenceData.state = movieLabel + } + else { + presenceData.name = movieName + presenceData.details = movieLabel + } + + presenceData.largeImageKey = showCover && cover ? cover : ActivityAssets.Logo + presenceData.largeImageText = movieName + applyVideoState(presenceData, showTime, showPlayState, s.play, s.pause) + applyButton(presenceData, showButton, href) + return presence.setActivity(presenceData) + } + + if (seriesMatch) { + const seriesName = schema?.name || cleanTitle(getOgTitle()) || 'Serie' + const cover = schema?.image || getOgImage() + + if (brand) { + presenceData.details = `Explorando ${seriesName}` + presenceData.state = 'Serie' + } + else { + presenceData.name = seriesName + presenceData.details = 'Serie' + } + + presenceData.largeImageKey = showCover && cover ? cover : ActivityAssets.Logo + presenceData.largeImageText = seriesName + presenceData.startTimestamp = browsingTimestamp + presenceData.smallImageKey = Assets.Search + presenceData.smallImageText = s.browse + applyButton(presenceData, showButton, href) + return presence.setActivity(presenceData) + } + + presenceData.details = 'Explorando SoloLatino' + presenceData.state = 'Navegando' + presenceData.startTimestamp = browsingTimestamp + presenceData.smallImageKey = Assets.Search + presenceData.smallImageText = s.browse + + if (pathname === '/' || pathname === '') { + presenceData.state = 'Página principal' + } + else if (/^\/(?:series|peliculas|animes|doramas)\/?$/.test(pathname)) { + presenceData.state = prettify(pathname.replace(/\//g, '')) + } + else if (/^\/genero\/[^/]+/.test(pathname)) { + presenceData.state = `Género: ${prettify(pathname.split('/')[2] ?? '')}` + } + else if (/^\/red\/[^/]+/.test(pathname)) { + presenceData.state = `Plataforma: ${prettify(pathname.split('/')[2] ?? '')}` + } + else if (pathname.startsWith('/buscar')) { + const query = new URLSearchParams(search).get('q') + presenceData.state = query ? truncate(`Buscando: ${query}`, 80) : 'Buscando contenido' + } + + return presence.setActivity(presenceData) +})