diff --git a/index.html b/index.html index 19e7eb87..763c87b9 100644 --- a/index.html +++ b/index.html @@ -6,27 +6,31 @@ - + + - + + - + + + - + - - - + + + ytify @@ -36,51 +40,99 @@ - +
+ +
+ + Current Playing Media Thumbnail +
+ Now Playing + +
+
+
-
+ - Current Playing Media Thumbnail +
- ytify 6.9 + Channel -
+
+ + +

00:00

+ +

00:00

+
+ +
+ + + + + + + + + + + + + + +
+
+
@@ -96,7 +148,7 @@ Remove - -
-

Listen more to discover more.

-
- -
- - History - - - -
-
-
- - Favorites - - - - -
-
+
+ + + Discover + + + + History + + + + Favorites + + + + Listen Later + +
+ + +
-
- - Listen Later - - - -
-
-
@@ -213,111 +234,144 @@

Listen more to discover more.

Clean
+
+
+ + - - - - - - - - - - - - - - -

Search

- Featured Playlists - Set YouTube Music as Default - Display Suggestions + + -

Playback

+ + + + + + + + +
- Highest Quality Audio - - - - +
+ + +

Search

+
+ Search as Default Tab + Songs as Default Filter + Display Suggestions +
-

Thumbnail

- Load - Lazy Loading - -

Library

- Store Discoveries - Store History - -

Interface

- - - - - - - - High Contrast Theme - Reverse Navigation - Toggle Fullscreen +
+ + +

Playback

+
+ + Highest Quality Audio + + + + + HLS / Live Streaming + + + + +
+
+ + +

Library

+
+ Store Discoveries + Store History +
-

Actions

+
+ + +

Interface

+
+ + + + + + + + + High Contrast Theme + Toggle Fullscreen +
- +
- + - Help + - Changelog + Help - Github + Changelog - Telegram + Github - Matrix + Telegram -
- -
+ Matrix +
+ +
+
@@ -328,12 +382,28 @@

Actions

- + + - + + + +
@@ -341,43 +411,6 @@

playlist or channel items show here

-
@@ -407,4 +440,4 @@

playlist or channel items show here

- + \ No newline at end of file diff --git a/netlify/edge-functions/opengraph.ts b/netlify/edge-functions/opengraph.ts new file mode 100644 index 00000000..ef1698a0 --- /dev/null +++ b/netlify/edge-functions/opengraph.ts @@ -0,0 +1,46 @@ +import { Context, Config } from '@netlify/edge-functions'; + +export default async (request: Request, context: Context) => { + + const req = new URL(request.url); + + if (!req.searchParams.has('s')) return; + + const id = req.searchParams.get('s'); + + if (id?.length !== 11) return; + + const response = await context.next(); + const page = await response.text(); + const instance = 'https://invidious.fdn.fr'; + const data = await fetch(instance + '/api/v1/videos/' + id).then(res => res.json()); + + // select the lowest bitrate aac stream i.e itag 139 + let audioSrc = data.adaptiveFormats.find((v: { itag: number }) => v.itag == 139).url; + + // Conditionally only proxy music streams + if (data.genre === 'Music') + audioSrc = audioSrc.replace(new URL(audioSrc).origin, instance); + + const newPage = page + .replace('48-160kbps Opus YouTube Audio Streaming Web App.', data.author) + .replace('"ytify"', `"${data.title}"`) + .replace(context.site.url, `${context.site.url}?s=${id}`) + .replaceAll('/ytify_thumbnail_min.webp', data.videoThumbnails.find((v: { quality: string }) => v.quality === 'medium').url) + // for audio embedding + .replace('', + ` + + + + ` + ) + .replace('"website"', '"music.song"'); + + + return new Response(newPage, response); +}; + +export const config: Config = { + path: '/*', +}; diff --git a/netlify/edge-functions/upcoming.ts b/netlify/edge-functions/upcoming.ts new file mode 100644 index 00000000..d44261ae --- /dev/null +++ b/netlify/edge-functions/upcoming.ts @@ -0,0 +1,75 @@ +// handles upcoming query requests to restore queue from any where + +import { Config } from '@netlify/edge-functions'; + +function convertSStoHHMMSS(seconds: number) { + if (seconds < 0) return ''; + const hh = Math.floor(seconds / 3600); + seconds %= 3600; + const mm = Math.floor(seconds / 60); + const ss = Math.floor(seconds % 60); + let mmStr = String(mm); + let ssStr = String(ss); + if (mm < 10) mmStr = '0' + mmStr; + if (ss < 10) ssStr = '0' + ssStr; + return (hh > 0 ? + hh + ':' : '') + `${mmStr}:${ssStr}`; +} + +const instanceArray = await fetch('https://piped-instances.kavin.rocks') + .then(res => res.json()) + .then(data => data.map((i: { api_url: string }) => i.api_url + '/streams/')) + .catch(() => ['https://pipedapi.kavin.rocks/streams/']); + +await fetch('https://api.invidious.io/instances.json') + .then(res => res.json()) + .then(data => { + for (const i of data) + if (i[1].cors && i[1].api) + instanceArray.push(i[1].uri + '/api/v1/videos/') + + }) + .catch(() => ['https://invidious.fdn.fr/api/v1/videos/']); + + +const getIndex = () => Math.floor(Math.random() * instanceArray.length); + + +export default async (request: Request) => { + + const uid = new URL(request.url).searchParams.get('id'); + + if (!uid) return; + + const array = []; + for (let i = 0; i < uid.length; i += 11) + array.push(uid.slice(i, i + 11)); + + const getData = async ( + id: string, + api: string = instanceArray[getIndex()] + ): Promise> => await fetch(api + id) + .then(res => res.json()) + .then(json => ({ + 'id': id, + 'title': json.title, + 'author': json.uploader || json.author, + 'authorId': json.authorUrl || json.uploaderUrl.slice(9), + 'duration': convertSStoHHMMSS(json.duration || json.lengthSeconds), + 'thumbnailUrl': json.thumbnailUrl || json.videoThumbnails[4].url, + 'source': api + id + })) + .catch(() => getData(id)) + + + const promises = array.map(async (id) => await getData(id)); + const response = await Promise.all(promises); + + return new Response(JSON.stringify(response), { + headers: { 'content-type': 'application/json' }, + }); +}; + +export const config: Config = { + path: '/upcoming', +}; diff --git a/package.json b/package.json index f61c3ae5..a98cf16b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ytify", - "version": "6.9.2", + "version": "7.0.0", "type": "module", "scripts": { "dev": "vite", @@ -9,15 +9,18 @@ "update": "npx npm-check-updates -u" }, "dependencies": { + "hls.js": "^1.5.8", "imsc": "^1.1.5", - "lit": "^3.1.3" + "solid-js": "^1.8.17" }, "devDependencies": { + "@netlify/edge-functions": "^2.8.1", "autoprefixer": "^10.4.19", "eruda": "^3.0.1", "typescript": "^5.4.5", - "vite": "^5.2.10", - "vite-plugin-pwa": "^0.19.8" + "vite": "^5.2.11", + "vite-plugin-pwa": "^0.20.0", + "vite-plugin-solid": "^2.10.2" }, "browserslist": [ "defaults" diff --git a/public/remixicon.woff2 b/public/remixicon.woff2 index f5af91fb..7b831c6c 100644 Binary files a/public/remixicon.woff2 and b/public/remixicon.woff2 differ diff --git a/src/components/ListItem.css b/src/components/ListItem.css new file mode 100644 index 00000000..63ebf27c --- /dev/null +++ b/src/components/ListItem.css @@ -0,0 +1,62 @@ +.listItem { + width: 48%; + height: auto; + background-color: var(--onBg); + padding: 1dvmin; + margin: 1%; + border: var(--border); + border-radius: calc(var(--roundness) + 0.75vmin); + transition: all 0.3s ease-out; + display: inline-block; + + @media(orientation:landscape) { + width: 23%; + } + + * { + pointer-events: none; + transition: all 0.4s; + } + + &:hover { + transform: scale(0.95); + } + + &.ravel { + * { + opacity: 0; + } + } + + + img { + height: auto; + width: 100%; + border-radius: var(--roundness); + } + + div { + display: flex; + flex-direction: column; + } + + p.title { + font-size: medium; + height: 2rem; + line-height: 1rem; + overflow: hidden; + } + + p.uData { + font-size: x-small; + line-height: 0.8rem; + overflow: hidden; + } + + p.stats { + font-size: small; + height: 1rem; + line-height: 1rem; + } + +} \ No newline at end of file diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx new file mode 100644 index 00000000..b727be22 --- /dev/null +++ b/src/components/ListItem.tsx @@ -0,0 +1,56 @@ +import { hostResolver } from '../lib/utils'; +import './ListItem.css'; +import { Show, createSignal } from 'solid-js'; + +// workaround "cannot access 'getSaved' before initialization" +const s = localStorage.getItem('imgLoad'); +const showImage = (s === 'off') ? undefined : s ? 'lazy' : 'eager'; + +export default function ListItem( + title: string, + stats: string, + thumbnail: string, + uploader_data: string, + url: string, +) { + const [getThumbnail, setThumbnail] = createSignal(thumbnail); + + const handleError = () => + setThumbnail( + getThumbnail().includes('rj') + ? getThumbnail().replace('rj', 'rw') + : '/logo192.png' + ); + + function handleLoad(e: Event) { + const img = e.target as HTMLImageElement; + img.parentElement!.classList.remove('ravel'); + + if (img.naturalHeight === 90) + setThumbnail(getThumbnail().replace('_webp', '').replace('webp', 'jpg')); + } + + + return ( + + + + +
+

{title}

+

{uploader_data}

+

{stats}

+
+
+ ); +} diff --git a/src/components/StreamItem.css b/src/components/StreamItem.css new file mode 100644 index 00000000..8dc4cb57 --- /dev/null +++ b/src/components/StreamItem.css @@ -0,0 +1,67 @@ +.streamItem { + width: 100%; + user-select: none; + background-color: var(--onBg); + padding: 1dvmin; + margin-bottom: 2dvmin; + border: var(--border); + border-radius: calc(var(--roundness) + 0.75vmin); + transition: transform 0.2s ease-out; + display: flex; + align-items: center; + + * { + pointer-events: none; + transition: opacity 0.4s; + } + + &:hover { + transform: scale(0.95); + } + + &.ravel { + * { + opacity: 0; + } + } + + + span { + position: relative; + z-index: 0; + width: 50dvmin; + margin-right: 1vmin; + + + img { + width: 100%; + border-radius: var(--roundness); + } + + .duration { + position: absolute; + padding: 0 1vmin; + bottom: 1.1vmin; + right: 1.2vmin; + background-color: #000a; + color: #fffc; + font-family: monospace; + border-radius: calc(var(--roundness)*1.1); + } + } + + div { + width: 100%; + + .avu { + font-size: small; + opacity: 0.8; + + @media(orientation:landscape) { + display: flex; + justify-content: space-between; + } + } + } + +} \ No newline at end of file diff --git a/src/components/StreamItem.tsx b/src/components/StreamItem.tsx new file mode 100644 index 00000000..249a5b31 --- /dev/null +++ b/src/components/StreamItem.tsx @@ -0,0 +1,99 @@ +import { Show, createSignal } from 'solid-js'; +import './StreamItem.css'; +import { instanceSelector } from '../lib/dom'; +import { getApi } from '../lib/utils'; +import { generateImageUrl } from '../lib/imageUtils'; + +// workaround "cannot access 'getSaved' before initialization" +const s = localStorage.getItem('imgLoad'); +const showImage = (s === 'off') ? undefined : s ? 'lazy' : 'eager'; + +export default function StreamItem(data: { + id: string, + title: string, + author: string, + duration: string, + href?: string, + uploaded?: string, + channelUrl?: string, + views?: string, + imgYTM?: string, + draggable?: boolean +}) { + + + const [tsrc, setTsrc] = createSignal(''); + + let parent!: HTMLAnchorElement; + + + function handleThumbnailLoad(e: Event) { + const img = e.target as HTMLImageElement; + const store = tsrc(); + + if (img.naturalWidth !== 120) { + parent.classList.remove('ravel'); + return; + } + if (store.includes('webp')) + setTsrc(store.replace('.webp', '.jpg').replace('vi_webp', 'vi')); + else { // most likely been removed from yt so remove it + parent.classList.add('delete'); + parent.click(); + } + } + + function handleThumbnailError() { + + const index = instanceSelector.selectedIndex; + const currentImgPrxy = getApi('image', index); + const nextImgPrxy = getApi('image', index + 1); + const store = tsrc(); + + parent.classList.remove('ravel'); + + + if (!store.includes(currentImgPrxy)) return; + + setTsrc(store.replace(currentImgPrxy, nextImgPrxy)); + } + + if (showImage) + setTsrc(generateImageUrl(data.imgYTM || data.id, 'mq')); + + return ( + + + + + +

{data.duration}

+
+ + + + +
+ ) +} diff --git a/src/components/UpdatePrompt.css b/src/components/UpdatePrompt.css new file mode 100644 index 00000000..31f4ac3f --- /dev/null +++ b/src/components/UpdatePrompt.css @@ -0,0 +1,53 @@ +#changelog { + display: flex; + flex-direction: column; + position: fixed; + z-index: 4; + height: 100%; + width: 100%; + justify-content: center; + align-items: center; + background-color: var(--bg); + animation: zoomout 0.5s ease-in-out forwards; + + + @keyframes zoomout { + from { + transform: scale(2); + opacity: 0; + } + } + + ul { + background-color: var(--onBg); + border-radius: calc(var(--roundness) + 0.75vmin); + color: var(--text); + box-shadow: var(--shadow); + border: var(--border); + padding: 2vmin; + overflow: hidden scroll; + max-height: 80dvh; + max-width: calc(100% - 10dvmin); + + li { + &:first-child { + list-style-type: none; + font-size: 1.5rem; + font-weight: bolder; + margin-bottom: 5%; + } + } + + hr { + border: 1px solid var(--text); + opacity: 0.5; + margin: 1rem 0; + } + } + + span { + display: flex; + justify-content: space-between; + } + +} \ No newline at end of file diff --git a/src/components/UpdatePrompt.tsx b/src/components/UpdatePrompt.tsx new file mode 100644 index 00000000..f9d38771 --- /dev/null +++ b/src/components/UpdatePrompt.tsx @@ -0,0 +1,48 @@ +import { createSignal } from "solid-js"; +import './UpdatePrompt.css'; + +export default function UpdatePrompt(handleUpdate: () => void) { + + const [list, setList] = createSignal([
  • Loading Update
  • ]); + const [fullList, setFullList] = createSignal(['']); + + + fetch('https://api.github.com/repos/n-ce/ytify/commits/main') + .then(res => res.json()) + .then(data => data.commit.message.split('-')) + .then(list => list.map((text: string) => (
  • {text}
  • ))) + .then(e => setList(e)) + + + function handleLater(e: Event) { + const dialog = ((e.target as HTMLElement).parentElement as HTMLUListElement).parentElement as HTMLDialogElement; + dialog.close(); + dialog.remove(); + } + + const handleFullList = () => + fetch('https://raw.githubusercontent.com/wiki/n-ce/ytify/Changelog.md') + .then(res => res.text()) + .then(text => text.split('\n')) + .then(e => setFullList(e)); + + + return ( + +
      + {list()} +
      + {fullList().length > 2 ? + fullList().map((text: string) => (
    • {text}
    • )) + : +
    • Read all previous changes
    • + } +
    + + + + +
    + ); + +} diff --git a/src/components/listItem.ts b/src/components/listItem.ts deleted file mode 100644 index ee316d64..00000000 --- a/src/components/listItem.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { getSaved } from "../lib/utils"; - -@customElement('list-item') -export class ListItem extends LitElement { - - @state() unravel = '0'; - @property() title!: string; - @property() stats!: string; - @property() thumbnail!: string; - @property() uploader_data!: string; - - handleError() { - this.thumbnail = this.thumbnail.includes('rj') ? this.thumbnail.replace('rj', 'rw') : '/logo192.png'; - } - - handleLoad(e:Event) { - this.unravel = '1'; - if ((e.target as HTMLImageElement).naturalHeight === 90) - this.thumbnail = this.thumbnail - .replace('_webp', '') - .replace('webp', 'jpg') - } - - render() { - return html` - -
    -

    ${this.title}

    -

    ${this.uploader_data}

    -

    ${this.stats}

    -
    - `; - } - - static styles = css` - :host { - background-color: var(--onBg); - height: 20vmin; - width: calc(100% - 2vmin); - margin-bottom: 1vmin; - padding: 1vmin; - border-radius: calc(var(--roundness) + 0.75vmin); - display: flex; - } - - img, div{ - opacity: 0; - transition: all 0.3s; - } - - p { - margin: 0; - padding: 0; - } - - img { - height: 100%; - border-radius: var(--roundness); - } - - div { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - margin-left: 1vmin; - } - - #title { - display: flex; - height: 10vmin; - font-size: medium; - overflow: hidden; - } - - #uData { - font-size: small; - height: 25%; - overflow: hidden; - } - - #stats { - font-size: medium; - height: 25%; - } - `; -} - diff --git a/src/components/streamItem.ts b/src/components/streamItem.ts deleted file mode 100644 index 45404e90..00000000 --- a/src/components/streamItem.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { LitElement, html, css } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { getSaved, getApi } from "../lib/utils"; -import { blankImage, generateImageUrl, sqrThumb } from "../lib/imageUtils"; -import { instanceSelector } from "../lib/dom"; - -@customElement('stream-item') -export class StreamItem extends LitElement { - - @query('span') span!: HTMLSpanElement - @query('#metadata') metadata!: HTMLDivElement - @query('#thumbnail') thumbnail!: HTMLImageElement - - @property() 'data-duration'!: string - @property() 'data-author'!: string - @property() 'data-title'!: string - @property() views!: string - @property() uploaded!: string - - @state() tsrc = blankImage - @state() unravel = '0' - - handleThumbnailLoad() { - if (this.thumbnail.naturalWidth !== 120) { - this.unravel = '1'; - return; - } - if (this.tsrc.includes('webp')) - this.thumbnail.src = this.tsrc.replace('.webp', '.jpg').replace('vi_webp', 'vi'); - else { // most likely been removed from yt so remove it - this.classList.add('delete'); - this.click(); - } - } - - handleThumbnailError() { - - const index = instanceSelector.selectedIndex; - const currentImgPrxy = getApi('image', index); - const nextImgPrxy = getApi('image', index + 1); - - this.unravel = '1'; - - if (!this.thumbnail.src.includes(currentImgPrxy)) return; - - this.thumbnail.src = this.tsrc.replace(currentImgPrxy, nextImgPrxy); - - } - - render() { - - if (getSaved('img') !== 'off') { - const img = generateImageUrl(this.dataset.id, 'mqdefault'); - if (location.search.endsWith('songs')) { - const x = new Image(); - x.onload = () => this.tsrc = sqrThumb(x); - x.src = img; - x.crossOrigin = ''; - } - else this.tsrc = img; - - } - - return html` - - -

    ${this["data-duration"]}

    -
    -
    -

    ${this["data-title"]}

    -
    -

    ${this["data-author"]}

    -

    ${(this.views || '') + (this.uploaded ? ' • ' + this.uploaded.replace('Streamed ', '') : '')}

    -
    -
    - `; - - } - - - static styles = css` - :host { - height: 20vmin; - width: calc(100% - 2vmin); - user-select: none; - background-color: var(--onBg); - padding: 1vmin; - margin-bottom: 1vmin; - border-radius: calc(var(--roundness) + 0.75vmin); - display: flex; - } - span,#metadata { - opacity: 0; - transition: all 0.3s; - } - p { - margin: 0; - padding: 0; - font-size: smaller; - overflow: hidden; - } - span { - position: relative; - z-index: 0; - height: 20vmin; - margin-right: 1vmin; - } - #thumbnail { - height: 100%; - border-radius: var(--roundness); - } - #duration { - position: absolute; - margin: 0; - padding: 0.5vmin 1vmin; - bottom: 1.1vmin; - right: 1.2vmin; - background-color: #0007; - color: #fffc; - font-weight: bold; - font-size: small; - border-radius: 1vmin; - } - #title { - font-size: 1rem; - line-height: 1.3rem; - height: 2.6rem; - word-break: break-all; - overflow: hidden; - } - div { - display: flex; - overflow: hidden; - } - #metadata { - display: flex; - flex-direction: column; - height: 100%; - width: 90%; - } - #avu { - display: flex; - flex-direction: column; - font-size:1rem; - opacity:0.8; - } - #author { - line-height:1rem; - max-height:1rem; - overflow:hidden; - } - - @media(orientation:landscape) { - #avu { - width: 100%; - display: inline-flex; - flex-direction: row; - justify-content: space-between; - } - #title{ - height:50%; - } - #author { - height: initial; - } - } - `; -} - diff --git a/src/components/toggleSwitch.css b/src/components/toggleSwitch.css new file mode 100644 index 00000000..0f508e8e --- /dev/null +++ b/src/components/toggleSwitch.css @@ -0,0 +1,47 @@ +:host { + display: flex; +} + +label { + margin-left: auto; + position: relative; + display: inline-flex; + pointer-events: none; + width: 2rem; +} + +span { + cursor: pointer; + inset: 0; + background-color: var(--onBg); + border-radius: var(--roundness); + border: var(--border); +} + +span:before { + position: absolute; + content: ""; + height: calc(100% - 0.5rem); + aspect-ratio: 1; + margin: 0.25rem; + background-color: var(--text); + border-radius: calc(var(--roundness) / 2); +} + +span, +span:before { + position: absolute; + transition: 0.3s; +} + +input { + display: none; +} + +input:checked+span { + background-color: var(--bg); +} + +input:checked+span:before { + transform: translateX(0.65rem); +} \ No newline at end of file diff --git a/src/components/toggleSwitch.ts b/src/components/toggleSwitch.ts index d7fa88b1..45301546 100644 --- a/src/components/toggleSwitch.ts +++ b/src/components/toggleSwitch.ts @@ -1,85 +1,43 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { $ } from '../lib/utils'; +import css from '../components/toggleSwitch.css?inline'; +let root: ShadowRoot; -@customElement('toggle-switch') -export class ToggleSwitch extends LitElement { - - static styles = css` - :host { - display: flex; - align-items: center; - margin: 4vmin 0; - color: inherit; - } - - label { - margin-left: auto; - position: relative; - display: inline-block; - pointer-events: none; - width: 9vmin; - height: 6vmin; - } +customElements.define('toggle-switch', class extends HTMLElement { + constructor() { + super(); + root = this.attachShadow({ mode: 'open' }); + const style = $('style'); + style.textContent = css; - span { - cursor: pointer; - inset: 0; - background-color: var(--onBg); - border-radius: var(--roundness); - border: var(--border); - transform: scale(1.1); - } + const label = $('label'); - span:before { - position: absolute; - content: ""; - height: calc(100% - 2.1vmin); - aspect-ratio:1; - margin: 1vmin; - background-color: var(--text); - border-radius: calc(var(--roundness) - 0.5vmin); - box-shadow:var(--shadow); - } + const input = $('input'); + input.type = 'checkbox'; - span, - span:before { - position: absolute; - transition: 0.3s; - } + this.addEventListener('click', () => { + input.checked = !input.checked; + }); - input { - display: none; - } + label.append(input, $('span')); - input:checked+span { - background-color: var(--bg); - } + root.append(style, $('slot'), label); + /* +