Update src/

This commit is contained in:
freearhey 2025-04-14 21:53:33 +03:00
parent 09b07e9b24
commit 86743c74f5
132 changed files with 4418 additions and 1907 deletions

View file

@ -0,0 +1,15 @@
export function clickOutside(node) {
const handleClick = event => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('outside', node))
}
}
document.addEventListener('click', handleClick, true)
return {
destroy() {
document.removeEventListener('click', handleClick, true)
}
}
}

1
src/actions/index.js Normal file
View file

@ -0,0 +1 @@
export * from './clickOutside'

View file

@ -1,7 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin "tailwind-scrollbar-hide";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-950: hsl(219, 23%, 5%);
--color-primary-900: hsl(219, 23%, 10%);
--color-primary-890: hsl(219, 23%, 11%);
--color-primary-880: hsl(219, 23%, 12%);
--color-primary-870: hsl(219, 23%, 13%);
--color-primary-860: hsl(219, 23%, 14%);
--color-primary-850: hsl(219, 23%, 15%);
--color-primary-840: hsl(219, 23%, 16%);
--color-primary-830: hsl(219, 23%, 17%);
--color-primary-820: hsl(219, 23%, 18%);
--color-primary-810: hsl(219, 23%, 19%);
--color-primary-800: hsl(219, 23%, 20%);
--color-primary-790: hsl(219, 23%, 21%);
--color-primary-780: hsl(219, 23%, 22%);
--color-primary-770: hsl(219, 23%, 23%);
--color-primary-760: hsl(219, 23%, 24%);
--color-primary-750: hsl(219, 23%, 25%);
--color-primary-740: hsl(219, 23%, 26%);
--color-primary-730: hsl(219, 23%, 27%);
--color-primary-720: hsl(219, 23%, 28%);
--color-primary-710: hsl(219, 23%, 29%);
--color-primary-700: hsl(219, 23%, 30%);
--color-primary-650: hsl(219, 23%, 35%);
--color-primary-600: hsl(219, 23%, 40%);
--color-primary-500: hsl(219, 23%, 50%);
--color-primary-400: hsl(219, 23%, 60%);
--color-primary-300: hsl(219, 23%, 70%);
--color-primary-200: hsl(219, 23%, 80%);
--color-primary-100: hsl(219, 23%, 90%);
--color-primary-50: hsl(219, 23%, 95%);
}
html {
overflow-y: scroll;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
}

52
src/commands/api/load.ts Normal file
View file

@ -0,0 +1,52 @@
import { ApiClient, DataLoader, DataProcessor } from '../../core'
import { DataStorage } from '../../core/dataStorage'
import cliProgress from 'cli-progress'
import numeral from 'numeral'
async function main() {
const progressBar = new cliProgress.MultiBar({
stopOnComplete: true,
hideCursor: true,
forceRedraw: true,
barsize: 36,
format(options, params, payload) {
const filename = payload.filename.padEnd(18, ' ')
const barsize = options.barsize || 40
const percent = (params.progress * 100).toFixed(2)
const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A'
const total = numeral(params.total).format('0.0 b')
const completeSize = Math.round(params.progress * barsize)
const incompleteSize = barsize - completeSize
const bar =
options.barCompleteString && options.barIncompleteString
? options.barCompleteString.substr(0, completeSize) +
options.barGlue +
options.barIncompleteString.substr(0, incompleteSize)
: '-'.repeat(barsize)
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
}
})
const storage = new DataStorage()
const processor = new DataProcessor()
const client = new ApiClient()
const dataLoader = new DataLoader({ storage, client, processor, progressBar })
const requests = [
dataLoader.download('channels.json'),
dataLoader.download('feeds.json'),
dataLoader.download('categories.json'),
dataLoader.download('countries.json'),
dataLoader.download('regions.json'),
dataLoader.download('subdivisions.json'),
dataLoader.download('timezones.json'),
dataLoader.download('languages.json'),
dataLoader.download('streams.json'),
dataLoader.download('guides.json'),
dataLoader.download('blocklist.json')
]
await Promise.all(requests)
}
main()

View file

@ -1,20 +0,0 @@
<script>
export let disabled = false
export let active = false
</script>
<button
{...$$restProps}
class="rounded-md transition-colors duration-200 border border-transparent text-white text-sm font-semibold text-center h-10 flex items-center justify-center space-x-3 px-4"
class:bg-gray-200={disabled || active}
class:bg-primary-600={!disabled && !active}
class:dark:bg-gray-700={disabled || active}
class:dark:hover:bg-primary-500={!disabled && !active}
class:dark:hover:bg-gray-700={active}
class:hover:bg-gray-200={disabled || active}
class:hover:bg-primary-700={!disabled && !active}
class:pointer-events-none={disabled}
on:click
>
<slot />
</button>

View file

@ -0,0 +1,5 @@
<div
class="text-gray-500 dark:text-gray-300 border-[1px] border-gray-200 text-xs inline-flex items-center h-5.5 px-2 py-0.5 rounded-full"
>
<slot />
</div>

View file

@ -1,49 +1,37 @@
<script>
<script lang="ts">
import { BlocklistRecord, Channel } from '~/models'
import Badge from '~/components/Badge.svelte'
import tippy from 'sveltejs-tippy'
export let channel
export let channel: Channel
let reason
let reason = 'dmca'
const messages = {
dmca: 'The channel has been added to our blocklist due to the claims of the copyright holder',
nsfw: 'The channel has been added to our blocklist due to NSFW content'
}
const blocklistRefs = channel._blocklistRecords
.map(record => {
let refName
const isIssue = /issues|pull/.test(record.ref)
const isAttachment = /github\.zendesk\.com\/attachments\/token/.test(record.ref)
if (isIssue) {
const parts = record.ref.split('/')
const issueId = parts.pop()
refName = `#${issueId}`
} else if (isAttachment) {
const [, filename] = record.ref.match(/\?name=(.*)/) || [null, undefined]
refName = filename
} else {
refName = record.ref.split('/').pop()
}
const blocklistRecordUrls = channel.blocklistRecords
.map((record: BlocklistRecord) => {
reason = record.reason
return `<a class="underline" target="_blank" rel="noreferrer" href="${record.ref}">${refName}</a>`
return `<a class="underline" target="_blank" rel="noreferrer" href="${
record.refUrl
}">${record.getRefLabel()}</a>`
})
.join(', ')
</script>
<div
class="text-gray-500 border-[1px] border-gray-200 text-xs inline-flex items-center px-2.5 py-0.5 dark:text-gray-300 rounded-full"
use:tippy={{
content: `${messages[reason]}: ${blocklistRefs}`,
allowHTML: true,
placement: 'right',
interactive: true
}}
href={channel.blocklist_ref}
target="_blank"
rel="noreferrer"
>
Blocked
</div>
<Badge>
<div
use:tippy={{
content: `${messages[reason]}: ${blocklistRecordUrls}`,
allowHTML: true,
interactive: true
}}
target="_blank"
rel="noreferrer"
>
Blocked
</div>
</Badge>

View file

@ -1,30 +1,28 @@
<script>
import { slide } from 'svelte/transition'
import DownloadButton from '~/components/DownloadButton.svelte'
import SelectAllButton from '~/components/SelectAllButton.svelte'
import Divider from '~/components/Divider.svelte'
import CloseButton from '~/components/CloseButton.svelte'
import { selected } from '~/store'
import { DownloadButton, SelectAllButton, ResetButton, CloseButton } from '~/components'
import { downloadMode } from '~/store'
import { selected } from '~/store'
</script>
<div
transition:slide={{ duration: 200 }}
class="h-16 bg-white dark:bg-gray-800 fixed bottom-0 left-0 right-0 py-2.5 border-t border-t-gray-100 dark:border-t-gray-800"
>
<div class="flex justify-between items-center max-w-5xl mx-auto px-3">
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
Selected {$selected.length.toLocaleString()} channel(s)
</div>
<div class="flex space-x-2 items-center">
<SelectAllButton />
<DownloadButton />
<Divider />
<CloseButton
on:click={() => {
downloadMode.set(false)
}}
/>
<div class="px-2 w-full fixed bottom-10 sm:bottom-20 flex justify-center pointer-events-none">
<div
class="h-16 px-3 py-3 w-full min-w-[300px] max-w-[calc(100%-16px)] sm:w-[540px] bg-primary-850 rounded-lg pointer-events-auto"
>
<div class="flex justify-between items-center w-full max-w-7xl">
<div class="text-sm text-gray-300 font-mono pl-2">
{$selected.count()} selected
</div>
<div class="flex space-x-1 sm:space-x-2 items-center">
<ResetButton />
<SelectAllButton />
<DownloadButton />
<CloseButton
onClick={() => {
downloadMode.set(false)
}}
variant="light"
/>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let label = 'Button'
export let onClick = () => {}
</script>
<button
type="button"
onclick={onClick}
class="w-full text-left rounded-md text-sm h-10 flex items-center text-gray-500 dark:text-gray-400 font-normal hover:bg-gray-100 dark:hover:bg-primary-750 space-x-3 px-2 border border-transparent cursor-pointer"
{...$$restProps}
>
<div class="w-5 h-5 flex items-center justify-center">
<slot name="left" />
</div>
<div class="w-full">{label}</div>
<div>
<slot name="right" />
</div>
</button>

View file

@ -0,0 +1,16 @@
<script>
export let border = false
let className = 'rounded-md bg-white dark:bg-primary-810'
if (border) className += ' border border-gray-200 dark:border-gray-700'
</script>
<div class={className}>
<div class="flex justify-between items-center pt-2 sm:pt-2.5 pl-4 pr-2.5 rounded-t w-full">
<slot name="headerLeft" />
<slot name="headerRight" />
</div>
<div>
<slot name="body" />
</div>
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'channels:edit',
template: '2_channels_edit.yml',
title: `Edit: ${channel.getUniqueName()}`,
id: channel.id
})
const editUrl = `${endpoint}?${params}`
function _onClick() {
onClick()
window.open(editUrl, '_blank')
}
</script>
<Button onClick={_onClick} label="Edit">
<Icon.Edit slot="left" class="text-gray-400" size={16} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,51 +1,36 @@
<script>
<script lang="ts">
import type { Collection } from '@freearhey/core/browser'
import ChannelItem from './ChannelItem.svelte'
import { query } from '~/store'
export let channels = []
export let channels: Collection
let limit = 100
$: channelsDisplay = channels.slice(0, limit)
query.subscribe(() => {
limit = 100
})
function showMore() {
limit += 100
}
</script>
<div class="flex flex-col bg-white dark:bg-gray-800">
<div class="overflow-y-auto scrollbar-hide">
<div class="inline-block min-w-full align-middle">
<div class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<div class="bg-gray-50 dark:bg-gray-700">
<div class="flex">
<div class="w-36 sm:w-[200px] shrink-0"></div>
<div
class="w-[216px] sm:w-80 py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400 shrink-0"
>
Name
</div>
<div
class="w-52 sm:w-[280px] py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400"
>
ID
</div>
<div>
<span class="sr-only">Actions</span>
</div>
</div>
</div>
<div>
{#each channelsDisplay as channel, idx (channel)}
<ChannelItem bind:channel />
{/each}
</div>
<div class="flex flex-col bg-white dark:bg-primary-810 rounded-b-md">
<div>
<div class="w-full inline-block min-w-full align-middle">
<div class="min-w-full w-full">
{#each channelsDisplay.all() as channel, index (channel.id)}
<ChannelItem bind:channel />
{/each}
</div>
</div>
</div>
{#if channelsDisplay.length < channels.length}
{#if channelsDisplay.count() < channels.count()}
<button
class="flex border-t border-gray-200 dark:border-gray-700 items-center justify-center h-12 w-full text-blue-500 dark:text-blue-400 hover:bg-gray-50 hover:dark:bg-gray-700 focus-visible:outline-0"
on:click={showMore}>Show More</button
class="flex border-t border-gray-200 dark:border-primary-700 items-center justify-center h-12 w-full text-blue-500 dark:text-blue-400 hover:bg-gray-50 hover:dark:bg-primary-750 focus-visible:outline-0 cursor-pointer"
onclick={showMore}>Show More</button
>
{/if}
</div>

View file

@ -1,45 +1,47 @@
<script>
import { getContext } from 'svelte'
import StreamsPopup from './StreamsPopup.svelte'
import GuidesPopup from './GuidesPopup.svelte'
import ChannelPopup from './ChannelPopup.svelte'
import Checkbox from './Checkbox.svelte'
import BlockedBadge from './BlockedBadge.svelte'
import ClosedBadge from './ClosedBadge.svelte'
<script lang="ts">
import type { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { downloadMode, selected } from '~/store'
import { fade } from 'svelte/transition'
import { pushState } from '$app/navigation'
import { fade } from 'svelte/transition'
import { getContext } from 'svelte'
import { pluralize } from '~/utils'
import { Channel } from '~/models'
import * as Icon from '~/icons'
import {
ChannelPopup,
BlockedBadge,
ClosedBadge,
CodeBlock,
FeedPopup,
Checkbox
} from '~/components'
export let channel
export let channel: Channel
const guides = channel._guides
const streams = channel._streams
const displayName = channel._displayName
const { open } = getContext<Context>('simple-modal')
const [name, country] = channel.id.split('.')
const { open } = getContext('simple-modal')
let prevUrl = '/'
const onOpened = () => {
function onOpened() {
prevUrl = window.location.href
pushState(`/channels/${country}/${name}`, {})
pushState(channel.getPagePath(), {})
}
const onClose = () => {
function onClose() {
pushState(prevUrl, {})
}
const showGuides = () =>
function showFeeds() {
open(
GuidesPopup,
{ guides, title: displayName },
FeedPopup,
{ feeds: channel.getFeeds(), channel },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showStreams = () =>
open(
StreamsPopup,
{ streams, title: displayName },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showChannelData = () => {
}
function showChannelData(event) {
event.preventDefault()
open(
ChannelPopup,
{ channel },
@ -48,138 +50,92 @@
)
}
function pluralize(number, word) {
return number > 1 ? word + 's' : word
}
function onCheckboxChange(event) {
selected.update(arr => {
if (event.detail.state) {
arr.push(channel)
function onCheckboxChange(state: boolean) {
selected.update((selectedChannels: Collection) => {
if (state) {
selectedChannels.push(channel)
} else {
arr = arr.filter(c => c.id !== channel.id)
selectedChannels = selectedChannels.filter(
(selectedChannel: Channel) => selectedChannel.id !== channel.id
)
}
return arr
return selectedChannels
})
}
$: isSelected = !!$selected.find(c => c.id === channel.id)
$: isDisabled = channel.streams === 0
$: isSelected = !!$selected.find((selectedChannel: Channel) => selectedChannel.id === channel.id)
$: isDisabled = channel.getStreams().isEmpty()
</script>
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-14 h-14 shrink-0 flex items-center absolute -left-14"
class="w-12 h-16 shrink-0 flex items-center absolute -left-14"
>
<Checkbox selected={isSelected} disabled={isDisabled} on:change={onCheckboxChange} />
<Checkbox selected={isSelected} disabled={isDisabled} onChange={onCheckboxChange} />
</div>
{/if}
<div
class="border-b last:border-b-0 border-gray-200 dark:border-gray-700 hover:bg-gray-50 hover:dark:bg-gray-700 h-16 flex items-center relative"
class="border-b last:border-b-0 last:rounded-b-md border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-primary-750 min-h-16 sm:h-16 py-2 flex items-center relative"
>
<div class="px-4 sm:pl-10 sm:pr-16 w-36 sm:w-[200px] flex shrink-0 items-center justify-center">
<div class="px-4 sm:pl-10 sm:pr-16 w-28 sm:w-[200px] flex shrink-0 items-center justify-center">
<div class="inline-flex items-center justify-center whitespace-nowrap overflow-hidden">
{#if channel.logo}
{#if channel.logoUrl}
<img
class="block align-middle mx-auto max-w-[6rem] max-h-[2.75rem] text-sm text-gray-400 dark:text-gray-600 cursor-defaults"
class="block align-middle mx-auto max-w-20 max-h-[2.75rem] text-sm text-gray-400 dark:text-gray-600 cursor-defaults"
loading="lazy"
referrerpolicy="no-referrer"
src={channel.logo}
alt={displayName}
src={channel.logoUrl}
alt={channel.getDisplayName()}
/>
{/if}
</div>
</div>
<div class="w-[216px] sm:w-80 px-2 shrink-0">
<div>
<div class="text-left">
<div class="flex space-x-2 items-center">
<a
on:click|preventDefault={showChannelData}
href="/channels/{country}/{name}"
tabindex="0"
class="font-normal text-gray-600 dark:text-white hover:underline hover:text-blue-500 truncate whitespace-nowrap"
title={displayName}
>
{displayName}
</a>
<div class="flex space-x-2">
{#if channel.is_closed}
<ClosedBadge {channel} />
{/if}
{#if channel.is_blocked}
<BlockedBadge {channel} />
{/if}
</div>
</div>
{#if channel.alt_names.length}
<div
class="text-sm text-gray-400 dark:text-gray-400 line-clamp-1"
title={channel.alt_names.join(', ')}
>
{channel.alt_names.join(', ')}
</div>
{/if}
</div>
</div>
</div>
<div class="w-52 sm:w-[280px] px-2">
<div>
<code
class="break-words text-sm text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-gray-700 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>{channel.id}</code
<div class="w-full sm:w-78 px-2 sm:shrink-0 overflow-hidden sm:overflow-auto">
<div class="flex items-center space-x-2 text-left">
<a
onclick={showChannelData}
href={channel.getPagePath()}
tabindex="0"
class="text-gray-600 dark:text-white hover:underline hover:text-blue-400 truncate whitespace-nowrap"
title={channel.getDisplayName()}
>
{channel.getDisplayName()}
</a>
{#if channel.isClosed()}
<div class="hidden sm:inline">
<ClosedBadge {channel} />
</div>
{/if}
{#if channel.isBlocked()}
<div class="hidden sm:inline">
<BlockedBadge {channel} />
</div>
{/if}
</div>
{#if channel.altNames.notEmpty()}
<div
class="text-sm text-gray-400 dark:text-gray-400 line-clamp-1"
title={channel.altNames.join(', ')}
>
{channel.altNames.join(', ')}
</div>
{/if}
</div>
<div class="w-56 pr-5 sm:w-[206px]">
<div class="w-54 sm:w-[280px] px-4 hidden sm:flex">
<CodeBlock>{channel.id}</CodeBlock>
</div>
<div class="sm:w-full px-3 sm:pl-4 sm:pr-5">
<div class="text-right flex justify-end space-x-3 items-center">
{#if guides.length}
{#if channel.hasFeeds()}
<button
on:click={showGuides}
class="text-sm text-gray-500 dark:text-gray-100 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400"
onclick={showFeeds}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H6a.75.75 0 01-.75-.75V12zM6 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H6zM7.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H8a.75.75 0 01-.75-.75V12zM8 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H8zM9.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V10zM10 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H10zM9.25 14a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V14zM12 9.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V10a.75.75 0 00-.75-.75H12zM11.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H12a.75.75 0 01-.75-.75V12zM12 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H12zM13.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H14a.75.75 0 01-.75-.75V10zM14 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H14z"
/>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
/>
</svg>
<div>{guides.length}</div>
<div>{pluralize(guides.length, 'guide')}</div>
</button>
{/if}{#if streams.length}
<button
on:click={showStreams}
class="text-sm text-gray-500 dark:text-gray-100 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
<div>{streams.length}</div>
<div>{pluralize(streams.length, 'stream')}</div>
<Icon.Feed size={20} />
<div>{channel.getFeeds().count()}</div>
<div class="hidden sm:block">{pluralize(channel.getFeeds().count(), 'feed')}</div>
</button>
{/if}
</div>

View file

@ -1,57 +1,72 @@
<script>
import HTMLPreview from '~/components/HTMLPreview.svelte'
import EditButton from '~/components/EditButton.svelte'
import Divider from '~/components/Divider.svelte'
import CloseButton from '~/components/CloseButton.svelte'
import BlockedBadge from './BlockedBadge.svelte'
import ClosedBadge from './ClosedBadge.svelte'
<script lang="ts">
import type { Context } from 'svelte-simple-modal'
import { toast } from '@zerodevx/svelte-toast'
import { getContext } from 'svelte'
import { Channel } from '~/models'
import {
ChannelRemoveButton,
ShareChannelButton,
ChannelEditButton,
CopyLinkButton,
BlockedBadge,
CloseButton,
ClosedBadge,
HTMLPreview,
Popup,
Card,
Menu
} from '~/components'
export let channel
export let channel: Channel
const { close } = getContext('simple-modal')
const isTouchDevice =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
const { close } = getContext<Context>('simple-modal')
window.onpopstate = event => {
if (event.target.location.pathname === '/') {
close()
}
}
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
function onLinkCopy() {
toast.push('Link copied to clipboard')
closeMenu()
}
</script>
<div
class="relative px-2 pt-20 pb-24 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-lg shadow dark:bg-gray-800 w-full max-w-[820px]">
<div
class="flex justify-between items-center py-3 pl-5 pr-3 md:pr-4 rounded-t border-b dark:border-gray-700"
>
<div class="w-2/3 overflow-hidden">
<div class="flex items-center space-x-3">
<h3 class="text-l font-medium text-gray-900 dark:text-white">{channel._displayName}</h3>
<div class="flex space-x-2">
{#if channel.is_closed}
<ClosedBadge {channel} />
{/if}
{#if channel.is_blocked}
<BlockedBadge {channel} />
{/if}
</div>
</div>
</div>
<div class="inline-flex w-1/3 justify-end space-x-2 items-center">
<EditButton {channel} />
<Divider />
<CloseButton on:click={close} />
<Popup onClose={close}>
<Card>
<div slot="headerLeft">
<div class="text-l font-medium text-gray-900 dark:text-white sm:pl-1 space-x-1">
<span>{channel.getDisplayName()}</span>
{#if channel.isClosed()}
<ClosedBadge {channel} />
{/if}
{#if channel.isBlocked()}
<BlockedBadge {channel} />
{/if}
</div>
</div>
<div class="overflow-y-auto overflow-x-scroll max-w-full scrollbar-hide">
<div class="inline-table px-5 py-5 sm:py-10 sm:px-12">
<HTMLPreview data={channel} {close} />
</div>
<div slot="headerRight" class="inline-flex w-30 shrink-0 items-center justify-end">
{#if isTouchDevice}
<ShareChannelButton {channel} />
{/if}
<Menu bind:isOpened={isMenuOpened}>
<CopyLinkButton link={channel.getPageUrl()} onCopy={onLinkCopy} />
<ChannelEditButton {channel} onClick={closeMenu} />
<ChannelRemoveButton {channel} onClick={closeMenu} />
</Menu>
<CloseButton onClick={close} />
</div>
</div>
</div>
<div slot="body" class="pt-4 pb-3 px-4 sm:py-9 sm:px-11">
<HTMLPreview fieldset={channel.getFieldset()} onClick={close} />
</div>
</Card>
</Popup>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'channels:remove',
template: '3_channels_remove.yml',
title: `Edit: ${channel.getUniqueName()}`,
id: channel.id
})
const url = `${endpoint}?${params}`
function _onClick() {
onClick()
window.open(url, '_blank')
}
</script>
<Button onClick={_onClick} label="Remove">
<Icon.Remove slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,69 +1,49 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
<script lang="ts">
import tippy from 'sveltejs-tippy'
import * as Icon from '~/icons'
export let selected = false
export let indeterminate = false
export let disabled = false
function toggle(state) {
dispatch('change', { state })
}
export let onChange = (state: boolean) => {}
</script>
{#if selected}
<button
class="w-12 h-12 rounded-full text-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Unselect"
on:click={() => toggle(false)}
onclick={() => onChange(false)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
/>
</svg>
<Icon.CheckboxChecked size={24} />
</button>
{:else if indeterminate}
<button
class="w-12 h-12 rounded-full text-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Unselect"
on:click={() => toggle(false)}
onclick={() => onChange(false)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm3 10.5a.75.75 0 000-1.5H9a.75.75 0 000 1.5h6z"
clip-rule="evenodd"
/>
</svg>
<Icon.CheckboxIndeterminate size={24} />
</button>
{:else if disabled}
<div
class="w-12 h-12 rounded-full text-gray-200 dark:text-gray-700 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-primary-200 dark:text-primary-700 transition-colors duration-200 flex items-center justify-center"
aria-label="Disabled"
title="No link to the broadcast"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<circle cx="12" cy="12" r="3" />
</svg>
<div
use:tippy={{
content: 'No links available',
placement: 'right'
}}
>
<Icon.CheckboxDisabled size={24} />
</div>
</div>
{:else}
<button
class="w-12 h-12 rounded-full text-gray-200 hover:text-gray-400 dark:text-gray-700 dark:hover:text-gray-600 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-primary-200 hover:text-primary-400 dark:text-primary-700 dark:hover:text-primary-600 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Select"
on:click={() => toggle(true)}
onclick={() => onChange(true)}
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10" fill="none" />
</svg>
<Icon.CheckboxUnchecked size={24} />
</button>
{/if}

View file

@ -1,9 +1,8 @@
<script>
import { onMount, tick, createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
import { tick } from 'svelte'
export let text
export let onCopy = () => {}
let textarea
@ -12,7 +11,7 @@
document.execCommand('Copy')
await tick()
textarea.blur()
dispatch('copy')
onCopy()
}
</script>

View file

@ -1,13 +1,11 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
export let variant = 'default'
export let onClick
</script>
<SquareButton on:click aria-label="Close">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</SquareButton>
<IconButton {onClick} aria-label="Close" title="Close" {variant}>
<Icon.Close size={20} />
</IconButton>

View file

@ -1,17 +1,19 @@
<script>
<script lang="ts">
import Badge from '~/components/Badge.svelte'
import tippy from 'sveltejs-tippy'
import { Channel } from '~/models'
export let channel
export let channel: Channel
</script>
<div
class="text-gray-500 border-[1px] border-gray-200 text-xs inline-flex items-center px-2.5 py-0.5 dark:text-gray-300 cursor-default rounded-full"
use:tippy={{
content: `closed: ${channel.closed}`,
allowHTML: true,
placement: 'right',
interactive: true
}}
>
Closed
</div>
<Badge>
<div
use:tippy={{
content: `closed: ${channel.closedDateString}`,
allowHTML: true,
interactive: true
}}
>
Closed
</div>
</Badge>

View file

@ -0,0 +1,7 @@
<div>
<code
class="break-words text-sm text-gray-500 bg-gray-100 dark:text-gray-300 dark:bg-primary-750 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>
<slot />
</code>
</div>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import Clipboard from '~/components/Clipboard.svelte'
import Button from '~/components/Button.svelte'
import * as Icon from '~/icons'
export let link: string
export let onCopy = () => {}
</script>
<Clipboard text={link} {onCopy} let:copy>
<Button onClick={copy} label="Copy Link">
<Icon.Link slot="left" class="text-gray-400" size={15} />
</Button>
</Clipboard>

View file

@ -1,46 +1,46 @@
<script>
<script lang="ts">
import Clipboard from '~/components/Clipboard.svelte'
import * as Icon from '~/icons'
export let text
let showTooltip = false
export let text: string
export let title = 'Copy to Clipboard'
let isCompleted = false
function onSuccess() {
showTooltip = true
isCompleted = true
setTimeout(() => {
showTooltip = false
isCompleted = false
}, 2000)
}
</script>
<Clipboard {text} on:copy={onSuccess} let:copy>
<Clipboard {text} onCopy={onSuccess} let:copy>
<button
type="button"
on:click={copy}
class="relative flex items-center justify-center text-xs text-gray-500 dark:text-gray-100 w-7 h-7"
aria-label="Copy to Clipboard"
onclick={copy}
disabled={isCompleted}
class="relative flex items-center justify-center text-xs w-7 h-7"
class:cursor-pointer={!isCompleted}
aria-label={title}
{title}
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
<span class="hidden">Copy to Clipboard</span>
<div
role="tooltip"
class:hidden={!showTooltip}
class="tooltip inline-block absolute right-10 top-0 py-2 px-3 text-xs text-gray-100 rounded-md bg-black"
>
Copied!
<div class="text-gray-400">
{#if isCompleted}
<Icon.Check size={20} />
{:else}
<Icon.Copy size={20} />
{/if}
</div>
<span class="hidden">Copy to Clipboard</span>
{#if isCompleted}
<div
role="tooltip"
class="tooltip absolute right-10 top-0 py-2 px-3 text-xs text-gray-100 rounded-md bg-black"
>
Copied!
</div>
{/if}
</button>
</Clipboard>

View file

@ -1,95 +1,124 @@
<script>
import ChannelGrid from './ChannelGrid.svelte'
import Checkbox from './Checkbox.svelte'
import { downloadMode, selected } from '~/store'
import _ from 'lodash'
<script lang="ts">
import { downloadMode, selected, hasQuery, searchResults, isReady } from '~/store'
import { ChannelGrid, Checkbox } from '~/components'
import { Collection } from '@freearhey/core/browser'
import { Channel, Country } from '~/models'
import { fade } from 'svelte/transition'
import * as Icon from '~/icons'
export let country
export let channels
export let hasQuery
export let country: Country
$: countryChannels = Array.isArray(channels) ? channels : []
$: hasStreams = countryChannels.filter(c => c.streams > 0)
$: expanded = country.expanded || (countryChannels && countryChannels.length > 0 && hasQuery)
$: intersect = _.intersectionBy($selected, hasStreams, 'id')
$: isIndeterminate = intersect.length !== 0 && intersect.length < hasStreams.length
$: isDisabled = hasStreams.length === 0
$: isSelected = intersect.length === hasStreams.length && hasStreams.length > 0
let isDisabled = false
let isSelected = false
let isIndeterminate = false
let isExpanded = false
function onExpand() {
country.expanded = !country.expanded
const channels: Collection = country.getChannels()
const channelsWithStreams: Collection = country.getChannelsWithStreams()
let filteredChannelsWithStreams: Collection = channelsWithStreams
let filteredChannels: Collection = channels
let selectedChannels: Collection = $selected
searchResults.subscribe((_searchResults: Collection) => {
onSearchResultsChange(_searchResults)
})
function onSearchResultsChange(_searchResults: Collection) {
isExpanded = false
if ($hasQuery) {
if ($isReady) isExpanded = true
if (_searchResults.isEmpty()) {
filteredChannels = new Collection()
} else {
filteredChannels = channels.intersectsBy(_searchResults, (channel: Channel) => channel.id)
}
} else {
filteredChannels = channels
}
filteredChannelsWithStreams = filteredChannels.filter((channel: Channel) =>
channel.hasStreams()
)
updateState()
}
function onCheckboxChange(event) {
hasStreams.forEach(channel => {
selected.update(arr => {
if (event.detail.state) {
arr.push(channel)
} else {
arr = arr.filter(c => c.id !== channel.id)
}
selected.subscribe((_selected: Collection) => {
selectedChannels = _selected
updateState()
})
return arr
})
})
function updateState() {
const selectedCountryChannels = filteredChannels.intersectsBy(
selectedChannels,
(channel: Channel) => channel.id
)
isDisabled = filteredChannelsWithStreams.isEmpty()
isSelected =
selectedCountryChannels.count() === filteredChannelsWithStreams.count() &&
filteredChannelsWithStreams.notEmpty()
isIndeterminate = selectedCountryChannels.count() > 0
}
function selectAll(state: boolean) {
if (state) {
const _selected = $selected.concat(filteredChannelsWithStreams)
selected.set(_selected)
} else {
const _selected = $selected.filter((channel: Channel) => channel.countryCode !== country.code)
selected.set(_selected)
}
}
function toggleExpanded() {
isExpanded = !isExpanded
}
</script>
<div class="mb-2 md:mb-3" class:pl-14={$downloadMode} style="transition: padding-left 100ms">
<h2 id="accordion-heading-{country.code}" class="flex relative">
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-14 h-14 shrink-0 flex items-center absolute -left-14"
{#if filteredChannels.notEmpty()}
<div class="mb-2 md:mb-3" class:pl-14={$downloadMode} style="transition: padding-left 100ms">
<div id="accordion-heading-{country.code}" class="flex relative">
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-12 h-13 shrink-0 flex items-center absolute -left-14"
>
<Checkbox
selected={isSelected}
disabled={isDisabled}
indeterminate={isIndeterminate}
onChange={selectAll}
/>
</div>
{/if}
<button
onclick={toggleExpanded}
type="button"
class="flex items-center focus:ring-0 dark:focus:ring-gray-800 justify-between h-13 pl-3.5 pr-4 w-full font-medium text-left border border-gray-200 dark:border-primary-750 text-gray-500 dark:text-white bg-white dark:bg-primary-810 cursor-pointer"
class:rounded-t-md={isExpanded}
class:rounded-md={!isExpanded}
class:border-b-transparent={isExpanded}
class:dark:border-b-transparent={isExpanded}
aria-expanded={isExpanded}
aria-controls="accordion-body-{country.code}"
>
<Checkbox
selected={isSelected}
disabled={isDisabled}
indeterminate={isIndeterminate}
on:change={onCheckboxChange}
/>
<span>{country.flagEmoji}&nbsp;{country.name}</span>
<div class="text-gray-400" class:rotate-180={isExpanded}>
<Icon.Expand size={20} />
</div>
</button>
</div>
{#if isExpanded}
<div
class="relative"
id="accordion-body-{country.code}"
aria-labelledby="accordion-heading-{country.code}"
>
<div class="border border-gray-200 dark:border-primary-750 rounded-b-md">
<ChannelGrid channels={filteredChannels} />
</div>
</div>
{/if}
<button
on:click={onExpand}
type="button"
class="flex items-center focus:ring-0 dark:focus:ring-gray-800 justify-between p-4 w-full font-medium text-left border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white bg-white dark:bg-gray-800"
class:rounded-t-md={expanded}
class:rounded-md={!expanded}
class:border-b-0={expanded}
aria-expanded={expanded}
aria-controls="accordion-body-{country.code}"
>
<span>{country.flag}&nbsp;{country.name}</span>
{#if !hasQuery}
<svg
class:rotate-180={expanded}
class="w-6 h-6 shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
</h2>
{#if expanded}
<div
class="relative"
id="accordion-body-{country.code}"
aria-labelledby="accordion-heading-{country.code}"
>
<div
class="border border-gray-200 dark:border-gray-700 dark:bg-gray-900 rounded-b-md overflow-hidden"
>
<ChannelGrid bind:channels />
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,34 +1,21 @@
<script>
import IconButton from '~/components/IconButton.svelte'
import { downloadMode } from '~/store'
import SquareButton from '~/components/SquareButton.svelte'
import { createEventDispatcher } from 'svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
export let onClick = () => {}
</script>
<SquareButton
<IconButton
active={$downloadMode}
on:click={() => {
onClick={() => {
downloadMode.set(!$downloadMode)
dispatch('click')
onClick()
}}
aria-label="Create playlist"
title="Create playlist"
>
<span class="inline">
<svg
fill="currentColor"
class="w-6 h-6"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm.5 4v-1.5c0-.265-.235-.5-.5-.5s-.5.235-.5.5v1.5h-1.5c-.265 0-.5.235-.5.5s.235.5.5.5h1.5v1.5c0 .265.235.5.5.5s.5-.235.5-.5c0-.592 0-1.5 0-1.5h1.5c.265 0 .5-.235.5-.5s-.235-.5-.5-.5c-.592 0-1.5 0-1.5 0zm-6.479 1c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm1.106-4c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
<Icon.CreatePlaylist size={20} />
</span>
</SquareButton>
</IconButton>

View file

@ -1,8 +0,0 @@
<button
{...$$restProps}
type="button"
on:click
class="rounded-lg text-sm h-10 flex items-center justify-center text-gray-500 dark:text-gray-400 font-normal hover:bg-gray-100 dark:hover:bg-gray-700 space-x-3 px-4 border border-transparent"
>
<slot />
</button>

View file

@ -1 +0,0 @@
<span class="w-[1px] h-[22px] bg-gray-200 dark:bg-gray-700"></span>

View file

@ -1,48 +1,58 @@
<script>
import ActionButton from './ActionButton.svelte'
import { selected, createPlaylist } from '~/store'
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import { Collection } from '@freearhey/core/browser'
import { Channel, Stream } from '~/models'
import { PlaylistCreator } from '~/core'
import { selected } from '~/store'
import * as Icon from '~/icons'
const playlistCreator = new PlaylistCreator()
function onClick() {
const playlist = createPlaylist()
const a = createDownloadLink(playlist.toString())
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
let streams = new Collection()
$selected.forEach((channel: Channel) => {
channel.getStreams().forEach((stream: Stream) => {
streams.add(stream)
})
})
streams = streams
.orderBy(
[
(stream: Stream) => stream.channelId.toLowerCase(),
(stream: Stream) => stream.getVerticalResolution(),
(stream: Stream) => stream.url
],
['asc', 'desc', 'asc']
)
.uniqBy((stream: Stream) => stream.channelId || stream.getUUID())
const playlist = playlistCreator.create(streams)
const downloadLink = createDownloadLink(playlist.toString())
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
}
function createDownloadLink(string) {
const blob = new Blob([string], { type: 'text/plain' })
const url = window.URL || window.webkitURL
const link = url.createObjectURL(blob)
const objUrl = url.createObjectURL(blob)
const a = document.createElement('a')
a.setAttribute('download', `playlist.m3u`)
a.setAttribute('href', link)
a.setAttribute('href', objUrl)
return a
}
</script>
<ActionButton on:click={onClick} disabled={!$selected.length} aria-label="Download Playlist">
<span class="inline">
<svg
fill="currentColor"
class="w-4 h-4"
viewBox="0 0 411 411"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4_46)">
<path
d="M205.5 297.333C202.075 297.333 198.864 296.802 195.867 295.74C192.87 294.678 190.087 292.855 187.519 290.269L95.0438 197.794C90.3344 193.084 87.9797 187.091 87.9797 179.813C87.9797 172.534 90.3344 166.541 95.0438 161.831C99.7531 157.122 105.858 154.664 113.359 154.459C120.86 154.253 126.956 156.497 131.648 161.189L179.812 209.353V25.6876C179.812 18.4095 182.278 12.3044 187.21 7.3724C192.142 2.4404 198.239 -0.0170361 205.5 8.88839e-05C212.778 8.88839e-05 218.883 2.46609 223.815 7.39809C228.747 12.3301 231.205 18.4266 231.187 25.6876V209.353L279.352 161.189C284.061 156.48 290.166 154.228 297.667 154.433C305.167 154.639 311.264 157.105 315.956 161.831C320.666 166.541 323.02 172.534 323.02 179.813C323.02 187.091 320.666 193.084 315.956 197.794L223.481 290.269C220.912 292.837 218.13 294.661 215.133 295.74C212.136 296.819 208.925 297.35 205.5 297.333ZM51.375 411C37.2469 411 25.1481 405.965 15.0786 395.896C5.0091 385.826 -0.0170814 373.736 4.36121e-05 359.625V308.25C4.36121e-05 300.972 2.46605 294.867 7.39804 289.935C12.33 285.003 18.4265 282.545 25.6875 282.562C32.9657 282.562 39.0707 285.028 44.0027 289.96C48.9347 294.892 51.3921 300.989 51.375 308.25V359.625H359.625V308.25C359.625 300.972 362.091 294.867 367.023 289.935C371.955 285.003 378.051 282.545 385.312 282.562C392.591 282.562 398.696 285.028 403.628 289.96C408.56 294.892 411.017 300.989 411 308.25V359.625C411 373.753 405.965 385.852 395.896 395.921C385.826 405.991 373.736 411.017 359.625 411H51.375Z"
/>
</g>
<defs>
<clipPath id="clip0_4_46">
<rect width="411" height="411" />
</clipPath>
</defs>
</svg>
</span>
<span class="hidden md:inline">Download</span>
</ActionButton>
<IconButton
{onClick}
disabled={!$selected.count()}
aria-label="Download Playlist"
title="Download Playlist"
variant="light"
>
<Icon.Download size={16} />
</IconButton>

View file

@ -1,74 +0,0 @@
<script>
import DefaultButton from '~/components/DefaultButton.svelte'
import SquareButton from '~/components/SquareButton.svelte'
import qs from 'qs'
export let channel
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const title = `Edit: ${channel._displayName}`
const labels = 'channels:edit'
const template = '2_channels_edit.yml'
let is_nsfw = null
if (channel.is_nsfw === true) is_nsfw = 'TRUE'
else if (channel.is_nsfw === false) is_nsfw = 'FALSE'
// let params = {
// labels,
// template,
// title,
// id: channel.id,
// name: channel.name,
// alt_names: channel.alt_names.join(';'),
// network: channel.network,
// owners: channel.owners.join(';'),
// country: channel.country,
// subdivision: channel.subdivision,
// city: channel.city,
// broadcast_area: channel.broadcast_area.join(';'),
// languages: channel.languages.join(';'),
// categories: channel.categories.join(';'),
// is_nsfw,
// launched: channel.launched,
// closed: channel.closed,
// replaced_by: channel.replaced_by,
// website: channel.website,
// logo: channel.logo
// }
let params = {
labels,
template,
title,
id: channel.id
}
params = qs.stringify(params)
const editUrl = `${endpoint}?${params}`
function goToEdit() {
window.open(editUrl, '_blank')
}
</script>
<div class="hidden md:block">
<DefaultButton on:click={goToEdit}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>
<span>Edit</span>
</DefaultButton>
</div>
<div class="block md:hidden">
<SquareButton on:click={goToEdit} aria-label="Edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>
</SquareButton>
</div>

View file

@ -1,27 +1,20 @@
<script>
import { createEventDispatcher } from 'svelte'
<script lang="ts">
import IconButton from './IconButton.svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
let expanded = false
export let expanded = false
export let onClick = (state: boolean) => {}
</script>
<button
class="w-7 h-7 flex justify-center align-middle text-gray-500 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-600 shrink-0 items-center"
on:click={() => {
<IconButton
onClick={() => {
expanded = !expanded
dispatch('click', { state: expanded })
onClick(expanded)
}}
size={32}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<svg
class="w-4 h-4"
class:rotate-90={expanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<div class:rotate-180={expanded}>
<Icon.Expand size={20} />
</div>
</IconButton>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:add',
template: '4_feeds_add.yml',
title: 'Add: ',
channel_id: channel.id
})
const url = `${endpoint}?${params}`
function _onClick() {
window.open(url, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Add feed">
<Icon.Add slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Feed } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let feed: Feed
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:edit',
template: '5_feeds_edit.yml',
title: `Edit: ${feed.getDisplayName()}`,
feed_id: feed.id,
channel_id: feed.channelId
})
const editUrl = `${endpoint}?${params}`
function _onClick() {
window.open(editUrl, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Edit">
<Icon.Edit slot="left" class="text-gray-400" size={16} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import type { Context } from 'svelte-simple-modal'
import { toast } from '@zerodevx/svelte-toast'
import { getContext } from 'svelte'
import { page } from '$app/state'
import * as Icon from '~/icons'
import { Feed } from '~/models'
import {
FeedRemoveButton,
CopyLinkButton,
FeedEditButton,
ExpandButton,
StreamsPopup,
HTMLPreview,
GuidesPopup,
CodeBlock,
Menu
} from '~/components'
export let feed: Feed
export let onClose = () => {}
const modal = getContext<Context>('simple-modal')
const hash = page.url.hash.replace('#', '').toLowerCase()
let isExpanded = (!hash && feed.isMain) || hash === feed.id.toLowerCase()
function showGuides() {
modal.open(
GuidesPopup,
{ guides: feed.getGuides(), title: 'Guides' },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
}
function showStreams() {
modal.open(
StreamsPopup,
{ streams: feed.getStreams(), title: 'Streams' },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
}
function _onClose() {
modal.close()
onClose()
}
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
function onLinkCopy() {
toast.push('Link copied to clipboard')
closeMenu()
}
</script>
<div class="w-full rounded-md border border-gray-200 dark:border-gray-700" id={feed.id}>
<div
class="w-full inline-flex justify-between px-2 py-1.5 border-gray-200 dark:border-gray-700"
class:border-b={isExpanded}
>
<div class="flex items-center w-full">
<div class="flex items-center w-full max-w-52 space-x-2 pr-3">
<ExpandButton bind:expanded={isExpanded} />
<div class="w-full text-gray-600 dark:text-white truncate">{feed.name}</div>
</div>
<div class="w-full hidden sm:flex">
<CodeBlock>{feed.id}</CodeBlock>
</div>
<div class="text-right flex justify-end items-center w-full">
<div class="flex space-x-5 items-center px-2 h-10">
{#if feed.hasStreams()}
<button
onclick={showStreams}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
title="Streams"
>
<Icon.Stream size={20} />
<div>{feed.getStreams().count()}</div>
</button>
{/if}
{#if feed.hasGuides()}
<button
onclick={showGuides}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
title="Streams"
>
<Icon.Guide size={20} />
<div>{feed.getGuides().count()}</div>
</button>
{/if}
</div>
<Menu bind:isOpened={isMenuOpened}>
<CopyLinkButton link={feed.getPageUrl()} onCopy={onLinkCopy} />
<FeedEditButton {feed} onClick={closeMenu} />
<FeedRemoveButton {feed} onClick={closeMenu} />
</Menu>
</div>
</div>
</div>
{#if isExpanded}
<div class="w-full flex px-6 py-6">
<HTMLPreview fieldset={feed.getFieldset()} onClick={_onClose} />
</div>
{/if}
</div>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { Popup, Card, Menu, FeedAddButton, CloseButton } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import type { Channel, Feed } from '~/models'
import FeedItem from './FeedItem.svelte'
import Modal from 'svelte-simple-modal'
import { getContext } from 'svelte'
import * as Icon from '~/icons'
export let channel: Channel
export let feeds: Collection = new Collection()
feeds = feeds.orderBy(
[(feed: Feed) => (feed.isMain ? 1 : 0), (feed: Feed) => feed.id],
['desc', 'asc']
)
const { close } = getContext<Context>('simple-modal')
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
</script>
<Popup onClose={close}>
<Card>
<div
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<Icon.Feed size={21} />
</span>{channel.getDisplayName()}
</div>
<div slot="headerRight" class="inline-flex">
<Menu bind:isOpened={isMenuOpened}>
<FeedAddButton {channel} onClick={closeMenu} />
</Menu>
<CloseButton onClick={close} />
</div>
<div slot="body" class="flex flex-col gap-2 p-2 sm:p-5">
<Modal
unstyled={true}
classBg="fixed top-0 left-0 z-80 w-screen h-screen flex flex-col bg-black/70 overflow-y-scroll"
closeButton={false}
>
{#each feeds.all() as feed, index (feed.getUUID())}
<FeedItem {feed} onClose={close} />
{/each}
</Modal>
</div>
</Card>
</Popup>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Feed } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let feed: Feed
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:remove',
template: '6_feeds_remove.yml',
title: `Edit: ${feed.getDisplayName()}`,
feed_id: feed.id,
channel_id: feed.channelId
})
const url = `${endpoint}?${params}`
function _onClick() {
window.open(url, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Remove">
<Icon.Remove slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,24 +1,12 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
function onClick() {
window.open('https://github.com/iptv-org/', '_blank', 'noreferrer')
}
</script>
<SquareButton
on:click={() => {
window.open('https://github.com/iptv-org/', '_blank', 'noreferrer')
}}
aria-label="GitHub"
>
<svg
class="w-5 h-5"
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
>
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
></path>
</svg>
</SquareButton>
<IconButton {onClick} aria-label="GitHub">
<Icon.GitHub size={20} />
</IconButton>

View file

@ -1,42 +1,30 @@
<script>
export let guide
<script lang="ts">
import { Guide } from '~/models'
import * as Icon from '~/icons'
const url = `https://${guide.site}`
export let guide: Guide
</script>
<div
class="w-full inline-flex justify-between px-4 border-b-[1px] dark:border-gray-700 last:border-0"
class="w-full inline-flex justify-between px-4 border-b-[1px] border-gray-200 dark:border-gray-700 last:border-0"
>
<div class="flex space-x-4 items-center w-full min-h-11 py-3">
<div class="text-gray-400 w-8 text-sm">{guide.lang}</div>
<div class="text-gray-400 w-8 text-sm">{guide.languageCode}</div>
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[50%] w-full"
href={url}
title={url}
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[50%] w-full items-center space-x-1"
href={guide.getUrl()}
title={guide.getUrl()}
target="_blank"
rel="noreferrer"
>
<span class="truncate">{guide.site}</span><span
class="inline-flex items-center pl-1 font-semibold text-gray-500 rounded-full"
<span class="truncate">{guide.siteDomain}</span><span
class="text-sm text-gray-400 dark:text-gray-500"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
></path>
</svg>
<Icon.ExternalLink size={16} />
</span></a
>
<div class="text-right text-gray-400 text-sm w-full" title={guide.site_id}>
{guide.site_name}
<div class="text-right text-gray-400 text-sm w-full" title={guide.siteId}>
{guide.siteName}
</div>
</div>
</div>

View file

@ -1,70 +1,38 @@
<script>
import GuideItem from '~/components/GuideItem.svelte'
<script lang="ts">
import { CloseButton, GuideItem, Popup, Card } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
const { close } = getContext('simple-modal')
import * as Icon from '~/icons'
export let title = 'Guides'
export let guides = []
export let guides: Collection = new Collection()
const { close } = getContext<Context>('simple-modal')
</script>
<div
class="relative px-2 py-32 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<Popup onClose={() => close()} wrapperClass="flex justify-center p-2 pt-16 sm:py-44 z-50">
<Card>
<div
class="flex justify-between items-center py-4 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H6a.75.75 0 01-.75-.75V12zM6 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H6zM7.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H8a.75.75 0 01-.75-.75V12zM8 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H8zM9.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V10zM10 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H10zM9.25 14a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V14zM12 9.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V10a.75.75 0 00-.75-.75H12zM11.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H12a.75.75 0 01-.75-.75V12zM12 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H12zM13.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H14a.75.75 0 01-.75-.75V10zM14 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H14z"
/>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
/>
</svg>
</span>{title}
</h3>
<button
on:click={close}
aria-label="Close"
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-full text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
<Icon.Guide size={20} />
</span>
<span>{title}</span>
</div>
<div class="overflow-y-auto overflow-x-hidden w-full">
<div class="p-6">
<div class="dark:border-gray-700 rounded-md border border-gray-200">
{#each guides as guide}
<GuideItem {guide} />
{/each}
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
<div slot="body" class="p-2 sm:p-5 w-full">
<div class="dark:border-gray-700 rounded-md border border-gray-200">
{#each guides.all() as guide}
<GuideItem {guide} />
{/each}
</div>
</div>
</div>
</div>
</Card>
</Popup>

View file

@ -1,142 +1,77 @@
<script>
import dayjs from 'dayjs'
<script lang="ts">
import type { HTMLPreviewField } from '~/types/htmlPreviewField'
export let data
export let close = () => {}
const fieldset = [
{ name: 'logo', type: 'image', value: data.logo, alt: `${data.name} logo`, title: data.logo },
{ name: 'id', type: 'string', value: data.id },
{ name: 'name', type: 'string', value: data.name },
{ name: 'alt_names', type: 'string', value: data.alt_names.join(', ') },
{
name: 'network',
type: 'link',
value: data.network ? { label: data.network, query: `network:${norm(data.network)}` } : null
},
{
name: 'owners',
type: 'link[]',
value: data.owners.map(value => ({ label: value, query: `owner:${norm(value)}` }))
},
{
name: 'country',
type: 'link',
value: { label: data._country.name, query: `country:${data._country.code}` }
},
{
name: 'subdivision',
type: 'link',
value: data._subdivision
? { label: data._subdivision.name, query: `subdivision:${data._subdivision.code}` }
: null
},
{
name: 'city',
type: 'link',
value: data.city ? { label: data.city, query: `city:${norm(data.city)}` } : null
},
{
name: 'broadcast_area',
type: 'link[]',
value: data._broadcastArea.map(v => ({
label: v.name,
query: `broadcast_area:${v.type}/${v.code}`
}))
},
{
name: 'languages',
type: 'link[]',
value: data._languages.map(v => ({ label: v.name, query: `language:${v.code}` }))
},
{
name: 'categories',
type: 'link[]',
value: data._categories.map(v => ({ label: v.name, query: `category:${v.id}` }))
},
{
name: 'is_nsfw',
type: 'link',
value: { label: data.is_nsfw.toString(), query: `is_nsfw:${data.is_nsfw.toString()}` }
},
{
name: 'launched',
type: 'date',
value: data.launched ? dayjs(data.launched).format('D MMMM YYYY') : null
},
{
name: 'closed',
type: 'date',
value: data.closed ? dayjs(data.closed).format('D MMMM YYYY') : null
},
{
name: 'replaced_by',
type: 'link',
value: data.replaced_by ? { label: data.replaced_by, query: `id:${data.replaced_by}` } : null
},
{ name: 'website', type: 'external_link', value: data.website }
].filter(f => (Array.isArray(f.value) ? f.value.length : f.value))
function norm(value) {
value = value.includes(' ') ? `"${value}"` : value
return encodeURIComponent(value)
}
export let fieldset: HTMLPreviewField[] = []
export let onClick = () => {}
</script>
<table class="table-fixed w-full">
<tbody>
{#each fieldset as field}
<tr class="overflow-hidden">
<td class="align-top w-[140px] sm:w-[180px]">
<tr>
<td class="align-top w-[140px] sm:w-[200px]">
<div class="flex pr-5 pb-3 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400">
{field.name}
</div>
</td>
<td class="align-top w-full overflow-hidden">
<div class="pb-3 text-sm text-gray-800 dark:text-gray-100">
<div class="pb-3 text-sm text-gray-900 dark:text-gray-100">
{#if field.type === 'image'}
<img
src={field.value}
alt={field.alt}
title={field.title}
src={field.value.src}
alt={field.value.alt}
title={field.value.title}
referrerpolicy="no-referrer"
class="border rounded-sm overflow-hidden border-gray-200 bg-[#e6e6e6]"
/>
{:else if field.type === 'link'}
<a
href="/?q={field.value.query}"
on:click={() => close()}
class="underline hover:text-blue-500"
title={field.value.label}
>
{field.value.label}
</a>
{:else if field.type === 'link[]'}
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<div class="truncate">
<a
href="/?q={value.query}"
on:click={() => close()}
class="underline hover:text-blue-500"
title={value.label}
href="/?q={field.value.query}"
onclick={onClick}
class="underline hover:text-blue-400"
title={field.value.label}
>
{value.label}
{field.value.label}
</a>
{/each}
</div>
{:else if field.type === 'link[]'}
<div class="overflow-hidden text-ellipsis">
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<a
href="/?q={value.query}"
onclick={onClick}
class="underline hover:text-blue-400"
title={value.label}
>
{value.label}
</a>
{/each}
</div>
{:else if field.type === 'external_link'}
<a
href={field.value}
class="underline hover:text-blue-500 truncate"
target="_blank"
rel="noopener noreferrer"
title={field.value}>{field.value}</a
>
<div class="truncate">
<a
href={field.value.href}
class="underline hover:text-blue-400"
target="_blank"
rel="noopener noreferrer"
title={field.value.title}>{field.value.label}</a
>
</div>
{:else if field.name === 'id'}
<span class="truncate" title={field.value}>{field.value}</span>
{:else}
<span title={field.value}>{field.value}</span>
<span class="break-all" title={field.value.toString()}>{field.value}</span>
{:else if field.type === 'string[]'}
<div class="overflow-hidden text-ellipsis">
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<span title={value.toString()}>{value}</span>
{/each}
</div>
{:else if field.type === 'string'}
<span title={field.title}>{field.value}</span>
{/if}
</div>
</td>

View file

@ -0,0 +1,20 @@
<script>
export let onClick = () => {}
export let variant = 'default'
export let size = 40
let className = 'rounded-lg text-sm flex items-center justify-center cursor-pointer shrink-0'
if (variant === 'light') className += ' hover:bg-primary-810 text-gray-300'
else className += ' hover:bg-gray-100 dark:hover:bg-primary-750 text-gray-400'
</script>
<button
type="button"
class={className}
style:width={`${size}px`}
style:height={`${size}px`}
onclick={onClick}
{...$$restProps}
>
<slot />
</button>

View file

@ -1,32 +1,31 @@
<script>
import { onMount } from 'svelte'
<script lang="ts">
import type { JsonDataViewerField } from '~/types/jsonDataViewerField'
import { JsonView } from '@zerodevx/svelte-json-view'
export let data
let dark = false
let fieldset = []
for (let key in data) {
if (key.startsWith('_')) continue
fieldset.push({
name: key,
value: data[key]
})
}
onMount(() => {
if (
localStorage.getItem('color-theme') === 'light' ||
(!('color-theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
dark = false
} else {
dark = true
}
})
export let fieldset: JsonDataViewerField[] = []
</script>
<table class="table-fixed w-full dark:text-white">
<tbody>
{#each fieldset as field}
<tr>
<td
class="w-[7rem] md:w-[11rem] px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400 align-top"
>
{field.name}
</td>
<td class="px-4 py-1 text-sm text-gray-600 dark:text-gray-100 align-top value break-words">
{#if Array.isArray(field.value) && field.value.length}
<JsonView json={field.value} />
{:else}
<code>{JSON.stringify(field.value)}</code>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<style>
:global(.value .val),
:global(.value .key) {
@ -59,24 +58,3 @@
color: #9ca3b0;
}
</style>
<table class="table-fixed w-full dark:text-white">
<tbody>
{#each fieldset as field}
<tr>
<td
class="w-[7rem] md:w-[11rem] px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400 align-top"
>
{field.name}
</td>
<td class="px-4 py-1 text-sm text-gray-600 dark:text-gray-100 align-top value break-words">
{#if Array.isArray(field.value) && field.value.length}
<JsonView json="{field.value}" />
{:else}
<code>{JSON.stringify(field.value)}</code>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>

View file

@ -0,0 +1,3 @@
<div class="flex space-x-2 w-29 text-primary-900 dark:text-primary-100 items-center">
<div class="font-mono font-semibold leading-4">/iptv_org</div>
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import { clickOutside } from '~/actions'
import * as Icon from '~/icons'
export let isOpened = false
function toggleMenu() {
isOpened = !isOpened
}
function closeMenu() {
isOpened = false
}
</script>
<div class="relative" use:clickOutside on:outside={closeMenu}>
<IconButton onClick={toggleMenu} aria-label="Menu">
<Icon.Menu size={16} />
</IconButton>
{#if isOpened}
<div
class="rounded-md bg-white dark:bg-primary-810 absolute top-11 right-0 w-48 z-10 p-1 border border-gray-200 dark:border-primary-750"
>
<slot />
</div>
{/if}
</div>

View file

@ -1,76 +1,120 @@
<script>
import { query, hasQuery, search } from '~/store'
import SearchButton from './SearchButton.svelte'
import SearchFieldMini from './SearchFieldMini.svelte'
import Divider from './Divider.svelte'
import CreatePlaylistButton from './CreatePlaylistButton.svelte'
import ToggleModeButton from './ToggleModeButton.svelte'
import GitHubButton from './GitHubButton.svelte'
import { setSearchParam } from '~/utils'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import {
CreatePlaylistButton,
ToggleModeButton,
GitHubButton,
SearchButton,
SearchField,
Logo
} from '~/components'
export let withSearch = false
export let version = 'default'
export let onSearchButtonClick = () => {}
function reset() {
document.body.scrollIntoView()
query.set('')
hasQuery.set(false)
search('')
}
let scrollY = 0
let input
function scrollToTop() {
document.body.scrollIntoView()
}
function reset() {
scrollToTop()
query.set('')
setSearchParam('q', '')
hasQuery.set(false)
isSearching.set(true)
setTimeout(() => {
search('')
}, 0)
}
function focusOnInput() {
if (input) input.focus()
}
</script>
<nav
class="bg-white border-b border-gray-200 py-2.5 dark:border-gray-700 dark:bg-gray-800 w-full h-[61px]"
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-6xl">
<div class="flex flex-start items-center sm:basis-88 shrink">
<a
href="/"
on:click={() => {
reset()
}}
class="flex mr-6"
>
<span
class="text-[1.15rem] text-[#24292f] self-center font-semibold whitespace-nowrap dark:text-white font-mono"
>/iptv-org</span
>
</a>
<div class="hidden sm:block w-full">
{#if withSearch}
<SearchFieldMini />
{/if}
</div>
</div>
<svelte:window bind:scrollY />
<div class="flex flex-end items-center space-x-4 pl-3">
<div class="inline-flex space-x-2">
{#if withSearch}
{#if version === 'default'}
<nav
class="py-2.5 w-full h-[61px] bg-[#f8fafc] dark:bg-primary-850 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700"
class:border-b={scrollY > 0}
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-7xl">
<div class="flex flex-start items-center sm:basis-120 shrink">
<a href="/" class="pr-2" onclick={reset}>
<Logo />
</a>
<div class="hidden sm:block w-full">
{#if scrollY > 150}
<SearchField
version="mini"
bind:this={input}
onClear={() => {
query.set('')
focusOnInput()
}}
onSubmit={() => {
goto(`/?q=${$query}`)
}}
/>
{/if}
</div>
</div>
<div class="inline-flex sm:space-x-1">
{#if scrollY > 150}
<div class="block sm:hidden">
<SearchButton
on:click={() => {
onClick={() => {
scrollToTop()
onSearchButtonClick()
}}
/>
</div>
{/if}
<CreatePlaylistButton
on:click={() => {
if ($page.url.pathname !== '/') {
goto('/')
}
}}
/>
</div>
<Divider />
<div class="inline-flex space-x-2">
<CreatePlaylistButton />
<ToggleModeButton />
<GitHubButton />
</div>
</div>
</div>
</nav>
</nav>
{:else if version === 'channelPage'}
<nav
class="py-2.5 w-full h-[61px] bg-[#f8fafc] dark:bg-primary-850 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700"
class:border-b={scrollY > 0}
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-7xl">
<div class="flex flex-start items-center sm:basis-120 shrink">
<a href="/" class="pr-2" onclick={reset}>
<Logo />
</a>
<div class="hidden sm:block w-full">
<SearchField
version="mini"
onClear={reset}
onSubmit={() => {
goto(`/?q=${$query}`)
}}
/>
</div>
</div>
<div class="inline-flex sm:space-x-1">
<div class="block sm:hidden">
<SearchButton
onClick={() => {
goto('/')
}}
/>
</div>
<ToggleModeButton />
<GitHubButton />
</div>
</div>
</nav>
{/if}

View file

@ -1,7 +0,0 @@
<button
{...$$restProps}
class="rounded-md bg-transparent transition-colors duration-200 border border-gray-200 hover:border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-100 dark:hover:bg-gray-700 dark:hover:border-gray-700 text-sm font-normal text-center px-4 h-10 flex items-center justify-center space-x-3"
on:click
>
<slot />
</button>

View file

@ -0,0 +1,8 @@
<script lang="ts">
export let wrapperClass = 'flex justify-center p-2 pb-20 sm:py-28 z-50'
export let onClose = () => {}
</script>
<div class={wrapperClass} role="presentation" on:keypress on:click|self={() => onClose()}>
<div class="w-full max-w-3xl"><slot /></div>
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Collection } from '@freearhey/core/browser'
import IconButton from './IconButton.svelte'
import { selected } from '~/store'
import * as Icon from '~/icons'
let isAnySelected = true
selected.subscribe((_selected: Collection) => {
isAnySelected = _selected.notEmpty()
})
function reset() {
selected.set(new Collection())
}
</script>
{#if isAnySelected}
<IconButton onClick={reset} aria-label="Reset" title="Reset" variant="light">
<Icon.Reset size={24} />
</IconButton>
{/if}

View file

@ -1,24 +1,12 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import { createEventDispatcher } from 'svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
export let onClick
</script>
<SquareButton
on:click={() => {
dispatch('click')
}}
aria-label="Go to search"
title="Go to search"
>
<IconButton {onClick} aria-label="Go to search" title="Go to search">
<span class="inline">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
<Icon.Search size={20} />
</span>
</SquareButton>
</IconButton>

View file

@ -1,68 +1,70 @@
<script>
import { getContext } from 'svelte'
import { query, search, setSearchParam } from '~/store'
import SearchSyntaxPopup from './SearchSyntaxPopup.svelte'
<script lang="ts">
import { query, isSearching } from '~/store'
import * as Icon from '~/icons'
const { open } = getContext('simple-modal')
export let version = 'default'
export let onClear = () => {}
export let onSubmit = () => {}
export let found = 0
export let isLoading = true
let input: HTMLElement
function onSubmit() {
setSearchParam('q', $query)
search($query)
export function blur() {
if (input) input.blur()
}
const showSearchSyntax = () => {
open(
SearchSyntaxPopup,
{},
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
export function focus() {
if (input) input.focus()
}
</script>
<form class="mb-5" on:submit|preventDefault={onSubmit}>
<div>
<label for="search-input" class="sr-only">Search</label>
<div class="relative mt-1">
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
</div>
<input
type="search"
id="search-input"
bind:value={$query}
class="bg-white border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Search for channels"
/>
<form
onsubmit={event => {
event.preventDefault()
blur()
onSubmit()
}}
autocomplete="off"
class:w-full={version === 'mini'}
>
<label for="search-input" class="sr-only">Search</label>
<div class="relative" class:w-full={version === 'mini'}>
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
{#if $isSearching}
<Icon.Spinner size={20} />
{:else}
<Icon.Search size={20} />
{/if}
</div>
<div class="mt-2 flex justify-between px-1">
<span class="inline-flex text-sm text-gray-500 dark:text-gray-400 font-mono pt-[2px]"
>Found&nbsp;
<span class:animate-spin={isLoading}>{!isLoading ? found.toLocaleString() : '/'}</span>
&nbsp;channel(s)</span
>
<button
type="button"
on:click|preventDefault={showSearchSyntax}
class="inline-flex text-sm text-gray-500 dark:text-gray-400 font-mono hover:underline hover:text-blue-500 dark:hover:text-blue-400 pt-[2px]"
>
Search syntax
</button>
<input
type="search"
id="search-input"
bind:this={input}
bind:value={$query}
class:h-10.5={version === 'default'}
class:h-9.5={version === 'mini'}
class="bg-white border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 py-2 px-1.5 dark:bg-primary-750 dark:border-primary-700 dark:placeholder-gray-400 dark:text-white placeholder-gray-400"
placeholder="Search"
/>
<div
class="absolute right-0 top-0 pr-1 text-gray-400 flex items-center"
class:h-10.5={version === 'default'}
class:h-9.5={version === 'mini'}
>
{#if $query.length}
<button
type="reset"
onmousedown={event => {
event.preventDefault()
onClear()
}}
class="cursor-pointer w-6 h-6"
title="Clear"
>
<Icon.Clear size={16} />
</button>
{/if}
</div>
</div>
</form>

View file

@ -1,39 +0,0 @@
<script>
import { query } from '~/store'
import { goto } from '$app/navigation'
function onSubmit() {
goto(`/?q=${$query}`)
}
</script>
<form on:submit|preventDefault={onSubmit} autocomplete="off" class="w-full">
<div class="w-full">
<label for="search-input" class="sr-only">Search</label>
<div class="relative w-full">
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
</div>
<input
type="search"
id="search-input"
bind:value={$query}
class="bg-gray-50 border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Search"
/>
</div>
</div>
</form>

View file

@ -1,12 +1,11 @@
<script>
import CloseButton from '~/components/CloseButton.svelte'
<script lang="ts">
import { Popup, CloseButton, CodeBlock, Card } from '~/components'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
export let title = 'Search syntax'
const { close } = getContext<Context>('simple-modal')
const { close } = getContext('simple-modal')
let examples = [
const examples = [
{ query: 'cat', result: 'Finds channels that have "cat" in their descriptions.' },
{ query: 'cat dog', result: 'Finds channels that have "cat" AND "dog" in their descriptions.' },
{ query: 'cat,dog', result: 'Finds channels that have "cat" OR "dog" in their descriptions.' },
@ -30,8 +29,13 @@
},
{ query: 'city:"San Francisco"', result: 'Finds all channels broadcast from San Francisco.' },
{ query: 'broadcast_area:c/CV', result: 'Finds channels that are broadcast in Cape Verde.' },
{
query: 'timezone:Asia/Kabul',
result: 'Find channels that are broadcast in the time zone Asia/Kabul.'
},
{ query: 'language:fra', result: 'Find channels that are broadcast in French.' },
{ query: 'category:news', result: 'Finds all the news channels.' },
{ query: 'video_format:1080p', result: 'Find channels that are broadcast in 1080p.' },
{ query: 'website:.', result: 'Finds channels that have a link to the official website.' },
{ query: 'is_nsfw:true', result: 'Finds channels marked as NSFW.' },
{
@ -43,51 +47,32 @@
result:
'Finds channels that have been added to our blocklist due to the claim of the copyright holder.'
},
{ query: 'feeds:>1', result: 'Finds channels with more than 1 feed.' },
{ query: 'streams:<2', result: 'Finds channels with less than 2 streams.' },
{ query: 'guides:>0', result: 'Finds channels that have guides.' }
]
</script>
<div
class="relative px-2 py-20 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<div
class="flex justify-between items-center py-3 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
<Popup onClose={close}>
<Card
><div
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
{title}
</h3>
<CloseButton on:click={close} />
Search syntax
</div>
<div class="overflow-y-auto overflow-x-scroll w-full scrollbar-hide">
<div class="text-gray-800 dark:text-white p-6 inline-block">
<table>
<thead>
<tr>
<th class="border p-2 dark:border-gray-700 font-semibold">Query</th>
<th class="border p-2 dark:border-gray-700 font-semibold">Result</th>
</tr>
</thead>
<tbody class="text-left">
{#each examples as example}
<tr class="even:bg-gray-50 even:dark:bg-gray-700">
<td class="border dark:border-gray-700 px-3 py-3 whitespace-nowrap min-w-[220px]">
<code
class="break-words text-sm text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-gray-700 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>{example.query}</code
>
</td>
<td class="border dark:border-gray-700 px-4 py-3 min-w-[260px]">{example.result}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
</div>
</div>
<div slot="body" class="text-gray-800 dark:text-white pt-2.5 w-full">
{#each examples as example}
<div
class="border-t border-gray-200 dark:border-gray-700 py-5 w-full flex flex-col items-start gap-2 px-5"
>
<CodeBlock>{example.query}</CodeBlock>
<div class="px-1">{example.result}</div>
</div>
{/each}
</div></Card
>
</Popup>

View file

@ -1,58 +1,101 @@
<script>
import OutlineButton from '~/components/OutlineButton.svelte'
import { selected, filteredChannels, channels } from '~/store'
<script lang="ts">
import { selected, channels, hasQuery, searchResults } from '~/store'
import { Collection } from '@freearhey/core/browser'
import IconButton from './IconButton.svelte'
import { Channel } from '~/models'
import * as Icon from '~/icons'
$: hasStreams = $channels.filter(c => c.streams > 0)
$: isAllSelected = $selected.length === hasStreams.length
const channelsWithStreams: Collection = $channels.filter((channel: Channel) =>
channel.hasStreams()
)
let filteredChannelsWithStreams: Collection = channelsWithStreams
let filteredChannels: Collection = $channels
let isLoading = false
let isAllSelected = false
searchResults.subscribe((_searchResults: Collection) => {
onSearchResultsChange(_searchResults)
})
function onSearchResultsChange(_searchResults: Collection) {
if ($hasQuery) {
if (_searchResults.isEmpty()) {
filteredChannels = new Collection()
} else {
filteredChannels = $channels.intersectsBy(_searchResults, (channel: Channel) => channel.id)
}
} else {
filteredChannels = $channels
}
filteredChannelsWithStreams = filteredChannels.filter((channel: Channel) =>
channel.hasStreams()
)
updateState()
}
selected.subscribe((_selected: Collection) => {
updateState()
})
function updateState() {
let _selected = $selected
isAllSelected = true
filteredChannelsWithStreams.forEach((channel: Channel) => {
const isChannelSelected = _selected.includes(
(selectedChannel: Channel) => selectedChannel.id === channel.id
)
if (!isChannelSelected) {
isAllSelected = false
return
}
})
}
function selectAll() {
selected.set(hasStreams)
isLoading = true
setTimeout(() => {
let _selected = $selected
filteredChannelsWithStreams.forEach((channel: Channel) => {
const isChannelSelected = _selected.includes(
(selectedChannel: Channel) => selectedChannel.id === channel.id
)
if (!isChannelSelected) {
_selected.add(channel)
}
})
selected.set(_selected)
isLoading = false
}, 0)
}
function deselectAll() {
selected.set([])
isLoading = true
setTimeout(() => {
let _selected = $selected
filteredChannelsWithStreams.forEach((channel: Channel) => {
_selected.remove((selectedChannel: Channel) => selectedChannel.id === channel.id)
})
selected.set(_selected)
isLoading = false
}, 0)
}
</script>
{#if isAllSelected}
<OutlineButton on:click={deselectAll} aria-label="Deselect All">
<span class="text-gray-500 dark:text-white">
<svg
fill="currentColor"
class="w-5 h-5"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm-5.979 5c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.979-1c-.592 0-3.408 0-4 0-.265 0-.5.235-.5.5s.235.5.5.5h4c.265 0 .5-.235.5-.5s-.235-.5-.5-.5zm-6.873-3c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
</span>
<span class="hidden md:inline">Deselect All</span>
</OutlineButton>
{#if isLoading}
<div class="h-10 w-10 flex items-center justify-center text-gray-100">
<Icon.Spinner size={21} />
</div>
{:else if isAllSelected}
<IconButton onClick={deselectAll} aria-label="Deselect All" title="Deselect All" variant="light">
<Icon.DeselectAll size={24} />
</IconButton>
{:else}
<OutlineButton on:click={selectAll} aria-label="Select All">
<span class="text-gray-500 dark:text-white">
<svg
fill="currentColor"
class="w-5 h-5"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm-5.979 5c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm3.704-.024 1.442 1.285c.095.085.215.127.333.127.136 0 .271-.055.37-.162l2.441-2.669c.088-.096.131-.217.131-.336 0-.274-.221-.499-.5-.499-.136 0-.271.055-.37.162l-2.108 2.304-1.073-.956c-.096-.085-.214-.127-.333-.127-.277 0-.5.224-.5.499 0 .137.056.273.167.372zm-2.598-3.976c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
</span>
<span class="hidden md:inline">Select All</span>
</OutlineButton>
<IconButton onClick={selectAll} aria-label="Select All" title="Select All" variant="light">
<Icon.SelectAll size={24} />
</IconButton>
{/if}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
export let channel: Channel
async function onClick() {
if (navigator.canShare) {
try {
navigator.share({
title: channel.getUniqueName(),
url: channel.getPageUrl()
})
} catch (err) {
console.log(err.message)
}
}
}
</script>
<IconButton {onClick}>
<Icon.Share class="text-gray-400" size={18} />
</IconButton>

View file

@ -1,8 +0,0 @@
<button
{...$$restProps}
type="button"
on:click
class="rounded-lg text-sm h-10 w-10 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<slot />
</button>

View file

@ -1,56 +1,46 @@
<script>
import CopyToClipboard from './CopyToClipboard.svelte'
import ExpandButton from './ExpandButton.svelte'
import JsonDataViewer from './JsonDataViewer.svelte'
<script lang="ts">
import { CopyToClipboard, ExpandButton, JsonDataViewer } from '~/components'
import { Stream } from '~/models'
import * as Icon from '~/icons'
export let stream
export let stream: Stream
let expanded = false
let isExpanded = false
</script>
<div
class="w-full bg-gray-100 dark:bg-gray-700 dark:border-gray-600 rounded-md border border-gray-200"
class="w-full bg-gray-100 dark:bg-primary-750 dark:border-gray-600 rounded-md border border-gray-200"
>
<div
class="w-full inline-flex justify-between px-3 py-2 border-gray-200 dark:border-gray-600"
class:border-b={expanded}
class="w-full inline-flex justify-between pl-2 pr-3 py-2 border-gray-200 dark:border-gray-600"
class:border-b={isExpanded}
>
<div class="flex space-x-3 items-center max-w-[90%] w-full">
<ExpandButton on:click={event => (expanded = event.detail.state)} />
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[80%] w-full"
href={stream.url}
title={stream.url}
target="_blank"
rel="noopener noreferrer"
>
<span class="truncate">{stream.url}</span><span
class="inline-flex items-center pl-1 text-sm font-semibold text-gray-500 rounded-full"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<div class="flex space-x-2 items-center w-full">
<ExpandButton bind:expanded={isExpanded} />
<div class="flex w-full items-center space-x-1 overflow-hidden">
<div class="truncate text-gray-600 dark:text-gray-100">
<a
class="whitespace-nowrap text-sm hover:text-blue-500 dark:hover:text-blue-400 hover:underline"
href={stream.url}
title={stream.url}
target="_blank"
rel="noopener noreferrer"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
></path>
</svg>
</span></a
>
</div>
<div class="flex shrink-0">
<CopyToClipboard text={stream.url} />
{stream.url}</a
>
</div>
<div class="text-sm text-gray-400 dark:text-gray-500">
<Icon.ExternalLink size={17} />
</div>
</div>
<div class="flex w-8 justify-end shrink-0">
<CopyToClipboard text={stream.url} />
</div>
</div>
</div>
{#if expanded}
{#if isExpanded}
<div class="w-full flex px-2 py-4">
<JsonDataViewer data={stream} />
<JsonDataViewer fieldset={stream.getFieldset()} />
</div>
{/if}
</div>

View file

@ -1,52 +1,35 @@
<script>
import CloseButton from '~/components/CloseButton.svelte'
import StreamItem from '~/components/StreamItem.svelte'
<script lang="ts">
import { CloseButton, StreamItem, Popup, Card } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
import * as Icon from '~/icons'
export let streams = []
export let streams: Collection = new Collection()
export let title = 'Streams'
const { close } = getContext('simple-modal')
const { close } = getContext<Context>('simple-modal')
</script>
<div
class="relative px-2 py-32 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<Popup onClose={() => close()} wrapperClass="flex justify-center p-2 pt-16 sm:py-44 z-50">
<Card>
<div
class="flex justify-between items-center py-3 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
</span>{title}
</h3>
<CloseButton on:click={close} />
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<Icon.Stream size={21} />
</span>{title}
</div>
<div class="overflow-y-auto overflow-x-hidden w-full">
<div class="p-6 space-y-2">
{#each streams as stream}
<StreamItem {stream} />
{/each}
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
</div>
</div>
<div slot="body" class="flex flex-col gap-2 p-2 sm:p-5">
{#each streams.all() as stream, index (stream.getUUID())}
<StreamItem {stream} />
{/each}
</div>
</Card>
</Popup>

View file

@ -1,6 +1,7 @@
<script>
import IconButton from './IconButton.svelte'
import { onMount } from 'svelte'
import SquareButton from './SquareButton.svelte'
import * as Icon from '~/icons'
let dark = false
function toggleDarkMode() {
@ -32,30 +33,10 @@
})
</script>
<SquareButton on:click={toggleDarkMode} aria-label="Toggle Dark Mode">
<svg
class="w-5 h-5"
class:hidden={dark}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
role="img"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg
class="w-5 h-5"
class:hidden={!dark}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</SquareButton>
<IconButton onClick={toggleDarkMode} aria-label="Toggle Dark Mode">
{#if dark}
<Icon.LightMode size={20} />
{:else}
<Icon.DarkMode size={20} />
{/if}
</IconButton>

45
src/components/index.ts Normal file
View file

@ -0,0 +1,45 @@
export { default as Badge } from './Badge.svelte'
export { default as BlockedBadge } from './BlockedBadge.svelte'
export { default as BottomBar } from './BottomBar.svelte'
export { default as Button } from './Button.svelte'
export { default as Card } from './Card.svelte'
export { default as ChannelEditButton } from './ChannelEditButton.svelte'
export { default as ChannelGrid } from './ChannelGrid.svelte'
export { default as ChannelItem } from './ChannelItem.svelte'
export { default as ChannelPopup } from './ChannelPopup.svelte'
export { default as ChannelRemoveButton } from './ChannelRemoveButton.svelte'
export { default as Checkbox } from './Checkbox.svelte'
export { default as Clipboard } from './Clipboard.svelte'
export { default as CloseButton } from './CloseButton.svelte'
export { default as ClosedBadge } from './ClosedBadge.svelte'
export { default as CodeBlock } from './CodeBlock.svelte'
export { default as CopyLinkButton } from './CopyLinkButton.svelte'
export { default as CopyToClipboard } from './CopyToClipboard.svelte'
export { default as CountryItem } from './CountryItem.svelte'
export { default as CreatePlaylistButton } from './CreatePlaylistButton.svelte'
export { default as DownloadButton } from './DownloadButton.svelte'
export { default as ExpandButton } from './ExpandButton.svelte'
export { default as FeedAddButton } from './FeedAddButton.svelte'
export { default as FeedEditButton } from './FeedEditButton.svelte'
export { default as FeedItem } from './FeedItem.svelte'
export { default as FeedPopup } from './FeedPopup.svelte'
export { default as FeedRemoveButton } from './FeedRemoveButton.svelte'
export { default as GitHubButton } from './GitHubButton.svelte'
export { default as GuideItem } from './GuideItem.svelte'
export { default as GuidesPopup } from './GuidesPopup.svelte'
export { default as HTMLPreview } from './HTMLPreview.svelte'
export { default as IconButton } from './IconButton.svelte'
export { default as JsonDataViewer } from './JsonDataViewer.svelte'
export { default as Logo } from './Logo.svelte'
export { default as Menu } from './Menu.svelte'
export { default as NavBar } from './NavBar.svelte'
export { default as Popup } from './Popup.svelte'
export { default as ResetButton } from './ResetButton.svelte'
export { default as SearchButton } from './SearchButton.svelte'
export { default as SearchField } from './SearchField.svelte'
export { default as SearchSyntaxPopup } from './SearchSyntaxPopup.svelte'
export { default as SelectAllButton } from './SelectAllButton.svelte'
export { default as ShareChannelButton } from './ShareChannelButton.svelte'
export { default as StreamItem } from './StreamItem.svelte'
export { default as StreamsPopup } from './StreamsPopup.svelte'
export { default as ToggleModeButton } from './ToggleModeButton.svelte'

15
src/core/apiClient.ts Normal file
View file

@ -0,0 +1,15 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
export class ApiClient {
instance: AxiosInstance
constructor() {
this.instance = axios.create({
baseURL: 'https://iptv-org.github.io/api/'
})
}
get(pathname: string, config?: AxiosRequestConfig): Promise<any> {
return this.instance.get(pathname, config)
}
}

143
src/core/dataLoader.ts Normal file
View file

@ -0,0 +1,143 @@
import { ApiClient } from './apiClient'
import { Collection } from '@freearhey/core/browser'
import type { DataProcessor } from './dataProcessor'
export type DataLoaderProps = {
client: ApiClient
processor: DataProcessor
storage?: any
progressBar?: any
}
export type DataLoaderData = {
channels: Collection
countries: Collection
regions: Collection
languages: Collection
subdivisions: Collection
categories: Collection
streams: Collection
guides: Collection
blocklistRecords: Collection
}
export class DataLoader {
client: ApiClient
processor: DataProcessor
storage: any
progressBar: any
constructor(props: DataLoaderProps) {
this.client = props.client
this.storage = props.storage
this.progressBar = props.progressBar
this.processor = props.processor
}
async load(): Promise<DataLoaderData> {
const [
countries,
regions,
subdivisions,
languages,
categories,
streams,
blocklist,
channels,
feeds,
timezones,
guides
] = await Promise.all([
this.fetch('countries.json'),
this.fetch('regions.json'),
this.fetch('subdivisions.json'),
this.fetch('languages.json'),
this.fetch('categories.json'),
this.fetch('streams.json'),
this.fetch('blocklist.json'),
this.fetch('channels.json'),
this.fetch('feeds.json'),
this.fetch('timezones.json'),
this.fetch('guides.json')
])
return this.processor.process({
countries,
regions,
subdivisions,
languages,
categories,
streams,
blocklist,
channels,
feeds,
timezones,
guides
})
}
async loadFromDisk(): Promise<DataLoaderData> {
const [
countries,
regions,
subdivisions,
languages,
categories,
streams,
blocklist,
channels,
feeds,
timezones,
guides
] = await Promise.all([
this.storage.load('countries.json'),
this.storage.load('regions.json'),
this.storage.load('subdivisions.json'),
this.storage.load('languages.json'),
this.storage.load('categories.json'),
this.storage.load('streams.json'),
this.storage.load('blocklist.json'),
this.storage.load('channels.json'),
this.storage.load('feeds.json'),
this.storage.load('timezones.json'),
this.storage.load('guides.json')
])
return this.processor.process({
countries,
regions,
subdivisions,
languages,
categories,
streams,
blocklist,
channels,
feeds,
timezones,
guides
})
}
async fetch(filename: string): Promise<any[]> {
return this.client.get(filename).then(response => response.data)
}
async download(filename: string) {
if (!this.storage || !this.progressBar) return
const stream = await this.storage.createStream(filename)
const progressBar = this.progressBar.create(0, 0, { filename })
this.client
.get(filename, {
responseType: 'stream',
onDownloadProgress({ total, loaded, rate }) {
if (total) progressBar.setTotal(total)
progressBar.update(loaded, { speed: rate })
}
})
.then(response => {
response.data.pipe(stream)
})
}
}

91
src/core/dataProcessor.ts Normal file
View file

@ -0,0 +1,91 @@
import { Collection } from '@freearhey/core/browser'
import {
Country,
Language,
Subdivision,
Region,
Category,
Stream,
Channel,
Guide,
BlocklistRecord,
Feed
} from '../models'
export class DataProcessor {
constructor() {}
process(data) {
const categories = new Collection(data.categories).map(data => new Category(data))
const categoriesKeyById = categories.keyBy((category: Category) => category.id)
let countries = new Collection(data.countries).map(data => new Country(data))
const countriesKeyByCode = countries.keyBy((country: Country) => country.code)
const subdivisions = new Collection(data.subdivisions).map(data => new Subdivision(data))
const subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code)
const regions = new Collection(data.regions).map(data => new Region(data))
const regionsKeyByCode = regions.keyBy((region: Region) => region.code)
const blocklistRecords = new Collection(data.blocklist).map(data => new BlocklistRecord(data))
const blocklistRecordsGroupedByChannelId = blocklistRecords.groupBy(
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId
)
let streams = new Collection(data.streams).map(data => new Stream(data))
const streamsGroupedByStreamId = streams.groupBy((stream: Stream) => stream.getId())
const guides = new Collection(data.guides).map(data => new Guide(data))
const guidesGroupedByStreamId = guides.groupBy((guide: Guide) => guide.getId())
const languages = new Collection(data.languages).map(data => new Language(data))
const languagesKeyByCode = languages.keyBy((language: Language) => language.code)
let feeds = new Collection(data.feeds).map(data =>
new Feed(data)
.withStreams(streamsGroupedByStreamId)
.withGuides(guidesGroupedByStreamId)
.withLanguages(languagesKeyByCode)
.withBroadcastArea(countriesKeyByCode, subdivisionsKeyByCode, regionsKeyByCode, regions)
)
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
const feedsKeyById = feeds.keyBy((feed: Feed) => feed.id)
let channels = new Collection(data.channels).map(data =>
new Channel(data)
.withCountry(countriesKeyByCode)
.withSubdivision(subdivisionsKeyByCode)
.withCategories(categoriesKeyById)
.withFeeds(feedsGroupedByChannelId)
.withBlocklistRecords(blocklistRecordsGroupedByChannelId)
)
const channelsGroupedByCountryCode = channels.groupBy((channel: Channel) => channel.countryCode)
const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
const channelsGroupedByName = channels.groupBy((channel: Channel) => channel.name)
channels = channels.map((channel: Channel) => channel.setHasUniqueName(channelsGroupedByName))
feeds = feeds.map((feed: Feed) => feed.withChannel(channelsKeyById))
streams = streams.map((stream: Stream) =>
stream.withChannel(channelsKeyById).withFeed(feedsKeyById)
)
countries = countries.map((country: Country) =>
country.withChannels(channelsGroupedByCountryCode)
)
return {
countries,
regions,
subdivisions,
languages,
categories,
streams,
blocklistRecords,
channels,
guides
}
}
}

34
src/core/dataStorage.ts Normal file
View file

@ -0,0 +1,34 @@
import * as path from 'path'
import fs from 'fs-extra'
export class DataStorage {
_rootDir: string
constructor(rootDir?: string) {
this._rootDir = 'temp/data'
}
async load(filename: string) {
const { default: data } = await import(`../../temp/data/${filename.replace('.json', '')}.json`)
return data
}
async createDir(dir: string): Promise<void> {
const absFilepath = path.isAbsolute(dir) ? path.resolve(dir) : path.join(this._rootDir, dir)
if (await fs.exists(absFilepath)) return
await fs.mkdir(absFilepath, { recursive: true }).catch(console.error)
}
async createStream(filepath: string): Promise<NodeJS.WriteStream> {
const absFilepath = path.isAbsolute(filepath)
? path.resolve(filepath)
: path.join(this._rootDir, filepath)
const dir = path.dirname(filepath)
await this.createDir(dir)
return fs.createWriteStream(absFilepath) as unknown as NodeJS.WriteStream
}
}

5
src/core/index.ts Normal file
View file

@ -0,0 +1,5 @@
export * from './apiClient'
export * from './dataLoader'
export * from './playlistCreator'
export * from './searchEngine'
export * from './dataProcessor'

View file

@ -0,0 +1,17 @@
import type { Collection } from '@freearhey/core/browser'
import { Playlist } from 'iptv-playlist-generator'
import { Stream } from '../../src/models'
export class PlaylistCreator {
constructor() {}
create(streams: Collection): Playlist {
const playlist = new Playlist()
streams.forEach((stream: Stream) => {
const link = stream.getPlaylistLink()
playlist.links.push(link)
})
return playlist
}
}

20
src/core/searchEngine.ts Normal file
View file

@ -0,0 +1,20 @@
import sjs from '@freearhey/search-js'
import { Collection } from '@freearhey/core/browser'
export class SearchEngine {
searchIndex: any
constructor() {}
createIndex(searchable: Collection) {
this.searchIndex = sjs.createIndex(searchable.all())
}
search(query: string): Collection {
if (!this.searchIndex || !query) return new Collection()
const results = this.searchIndex.search(query)
return new Collection(results)
}
}

17
src/icons/Add.svelte Normal file
View file

@ -0,0 +1,17 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14m-7 7V5"
/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

16
src/icons/Check.svelte Normal file
View file

@ -0,0 +1,16 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 12.6111L8.92308 17.5L20 6.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View file

@ -0,0 +1,14 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

View file

@ -0,0 +1,10 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 196 B

View file

@ -0,0 +1,14 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm3 10.5a.75.75 0 000-1.5H9a.75.75 0 000 1.5h6z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View file

@ -0,0 +1,11 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 232 B

13
src/icons/Clear.svelte Normal file
View file

@ -0,0 +1,13 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M8,16 C12.4183,16 16,12.4183 16,8 C16,3.58172 12.4183,0 8,0 C3.58172,0 0,3.58172 0,8 C0,12.4183 3.58172,16 8,16 Z M4.29289,4.29289 C4.68342,3.90237 5.31658,3.90237 5.70711,4.29289 L8,6.58579 L10.2929,4.29289 C10.6834,3.90237 11.3166,3.90237 11.7071,4.29289 C12.0976,4.68342 12.0976,5.31658 11.7071,5.70711 L9.41421,8 L11.7071,10.2929 C12.0976,10.6834 12.0976,11.3166 11.7071,11.7071 C11.3166,12.0976 10.6834,12.0976 10.2929,11.7071 L8,9.41421 L5.70711,11.7071 C5.31658,12.0976 4.68342,12.0976 4.29289,11.7071 C3.90237,11.3166 3.90237,10.6834 4.29289,10.2929 L6.58579,8 L4.29289,5.70711 C3.90237,5.31658 3.90237,4.68342 4.29289,4.29289 Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

14
src/icons/Close.svelte Normal file
View file

@ -0,0 +1,14 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>

After

Width:  |  Height:  |  Size: 431 B

16
src/icons/Copy.svelte Normal file
View file

@ -0,0 +1,16 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 398 B

View file

@ -0,0 +1,15 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M47 157C47 142.088 59.0883 130 74 130H437C451.912 130 464 142.088 464 157C464 171.912 451.912 184 437 184H74C59.0883 184 47 171.912 47 157ZM47 286C47 271.088 59.0883 259 74 259H330C344.912 259 357 271.088 357 286C357 300.912 344.912 313 330 313H74C59.0883 313 47 300.912 47 286ZM74 386C59.0883 386 47 398.088 47 413C47 427.912 59.0883 440 74 440H215C229.912 440 242 427.912 242 413C242 398.088 229.912 386 215 386H74ZM289 414C289 399.088 301.088 387 316 387H392V313C392 298.088 404.088 286 419 286C433.912 286 446 298.088 446 313V387H521C535.912 387 548 399.088 548 414C548 428.912 535.912 441 521 441H446V518C446 532.912 433.912 545 419 545C404.088 545 392 532.912 392 518V441H316C301.088 441 289 428.912 289 414Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 963 B

13
src/icons/DarkMode.svelte Normal file
View file

@ -0,0 +1,13 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
role="img"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>

After

Width:  |  Height:  |  Size: 301 B

View file

@ -0,0 +1,16 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 12C21 13.1819 20.7672 14.3522 20.3149 15.4442C19.8626 16.5361 19.1997 17.5282 18.364 18.364C17.5282 19.1997 16.5361 19.8626 15.4442 20.3149C14.3522 20.7672 13.1819 21 12 21C10.8181 21 9.64778 20.7672 8.55585 20.3149C7.46392 19.8626 6.47177 19.1997 5.63604 18.364C4.80031 17.5282 4.13738 16.5361 3.68508 15.4442C3.23279 14.3522 3 13.1819 3 12C3 9.61305 3.94821 7.32387 5.63604 5.63604C7.32387 3.94821 9.61305 3 12 3C14.3869 3 16.6761 3.94821 18.364 5.63604C20.0518 7.32387 21 9.61305 21 12Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

19
src/icons/Download.svelte Normal file
View file

@ -0,0 +1,19 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 411 411"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4_46)">
<path
d="M205.5 297.333C202.075 297.333 198.864 296.802 195.867 295.74C192.87 294.678 190.087 292.855 187.519 290.269L95.0438 197.794C90.3344 193.084 87.9797 187.091 87.9797 179.813C87.9797 172.534 90.3344 166.541 95.0438 161.831C99.7531 157.122 105.858 154.664 113.359 154.459C120.86 154.253 126.956 156.497 131.648 161.189L179.812 209.353V25.6876C179.812 18.4095 182.278 12.3044 187.21 7.3724C192.142 2.4404 198.239 -0.0170361 205.5 8.88839e-05C212.778 8.88839e-05 218.883 2.46609 223.815 7.39809C228.747 12.3301 231.205 18.4266 231.187 25.6876V209.353L279.352 161.189C284.061 156.48 290.166 154.228 297.667 154.433C305.167 154.639 311.264 157.105 315.956 161.831C320.666 166.541 323.02 172.534 323.02 179.813C323.02 187.091 320.666 193.084 315.956 197.794L223.481 290.269C220.912 292.837 218.13 294.661 215.133 295.74C212.136 296.819 208.925 297.35 205.5 297.333ZM51.375 411C37.2469 411 25.1481 405.965 15.0786 395.896C5.0091 385.826 -0.0170814 373.736 4.36121e-05 359.625V308.25C4.36121e-05 300.972 2.46605 294.867 7.39804 289.935C12.33 285.003 18.4265 282.545 25.6875 282.562C32.9657 282.562 39.0707 285.028 44.0027 289.96C48.9347 294.892 51.3921 300.989 51.375 308.25V359.625H359.625V308.25C359.625 300.972 362.091 294.867 367.023 289.935C371.955 285.003 378.051 282.545 385.312 282.562C392.591 282.562 398.696 285.028 403.628 289.96C408.56 294.892 411.017 300.989 411 308.25V359.625C411 373.753 405.965 385.852 395.896 395.921C385.826 405.991 373.736 411.017 359.625 411H51.375Z"
/>
</g>
<defs>
<clipPath id="clip0_4_46">
<rect width="411" height="411" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

12
src/icons/Edit.svelte Normal file
View file

@ -0,0 +1,12 @@
<svg
{...$$restProps}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width={$$props.size}
height={$$props.size}
>
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

16
src/icons/Expand.svelte Normal file
View file

@ -0,0 +1,16 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 9L12 16L5 9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View file

@ -0,0 +1,19 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
id="Vector"
d="M10.0002 5H8.2002C7.08009 5 6.51962 5 6.0918 5.21799C5.71547 5.40973 5.40973 5.71547 5.21799 6.0918C5 6.51962 5 7.08009 5 8.2002V15.8002C5 16.9203 5 17.4801 5.21799 17.9079C5.40973 18.2842 5.71547 18.5905 6.0918 18.7822C6.5192 19 7.07899 19 8.19691 19H15.8031C16.921 19 17.48 19 17.9074 18.7822C18.2837 18.5905 18.5905 18.2839 18.7822 17.9076C19 17.4802 19 16.921 19 15.8031V14M20 9V4M20 4H15M20 4L13 11"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

17
src/icons/Feed.svelte Normal file
View file

@ -0,0 +1,17 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 8v8m0-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm8-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 0a4 4 0 0 1-4 4h-1a3 3 0 0 0-3 3"
/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

15
src/icons/GitHub.svelte Normal file
View file

@ -0,0 +1,15 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
>
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

17
src/icons/Guide.svelte Normal file
View file

@ -0,0 +1,17 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M5.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H6a.75.75 0 01-.75-.75V12zM6 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H6zM7.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H8a.75.75 0 01-.75-.75V12zM8 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H8zM9.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V10zM10 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H10zM9.25 14a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V14zM12 9.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V10a.75.75 0 00-.75-.75H12zM11.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H12a.75.75 0 01-.75-.75V12zM12 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H12zM13.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H14a.75.75 0 01-.75-.75V10zM14 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H14z"
/>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,14 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>

After

Width:  |  Height:  |  Size: 737 B

21
src/icons/Link.svelte Normal file
View file

@ -0,0 +1,21 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.05025 1.53553C8.03344 0.552348 9.36692 0 10.7574 0C13.6528 0 16 2.34721 16 5.24264C16 6.63308 15.4477 7.96656 14.4645 8.94975L12.4142 11L11 9.58579L13.0503 7.53553C13.6584 6.92742 14 6.10264 14 5.24264C14 3.45178 12.5482 2 10.7574 2C9.89736 2 9.07258 2.34163 8.46447 2.94975L6.41421 5L5 3.58579L7.05025 1.53553Z"
fill="currentColor"
/>
<path
d="M7.53553 13.0503L9.58579 11L11 12.4142L8.94975 14.4645C7.96656 15.4477 6.63308 16 5.24264 16C2.34721 16 0 13.6528 0 10.7574C0 9.36693 0.552347 8.03344 1.53553 7.05025L3.58579 5L5 6.41421L2.94975 8.46447C2.34163 9.07258 2 9.89736 2 10.7574C2 12.5482 3.45178 14 5.24264 14C6.10264 14 6.92742 13.6584 7.53553 13.0503Z"
fill="currentColor"
/>
<path
d="M5.70711 11.7071L11.7071 5.70711L10.2929 4.29289L4.29289 10.2929L5.70711 11.7071Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1,000 B

21
src/icons/Menu.svelte Normal file
View file

@ -0,0 +1,21 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 12C9.10457 12 10 12.8954 10 14C10 15.1046 9.10457 16 8 16C6.89543 16 6 15.1046 6 14C6 12.8954 6.89543 12 8 12Z"
fill="currentColor"
/>
<path
d="M8 6C9.10457 6 10 6.89543 10 8C10 9.10457 9.10457 10 8 10C6.89543 10 6 9.10457 6 8C6 6.89543 6.89543 6 8 6Z"
fill="currentColor"
/>
<path
d="M10 2C10 0.89543 9.10457 -4.82823e-08 8 0C6.89543 4.82823e-08 6 0.895431 6 2C6 3.10457 6.89543 4 8 4C9.10457 4 10 3.10457 10 2Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

17
src/icons/Remove.svelte Normal file
View file

@ -0,0 +1,17 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

15
src/icons/Reset.svelte Normal file
View file

@ -0,0 +1,15 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 25 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.01409 11.5886C6.62899 7.21335 10.6743 4.16497 15.0496 4.77987C19.4249 5.39478 22.4733 9.44012 21.8584 13.8154C21.2435 18.1907 17.1981 21.2391 12.8228 20.6242C11.944 20.5007 11.1206 20.2394 10.3701 19.866C9.87569 19.6199 9.2754 19.8213 9.02937 20.3158C8.78333 20.8102 8.98472 21.4105 9.47917 21.6565C10.4198 22.1246 11.4499 22.4509 12.5445 22.6047C18.0136 23.3733 23.0703 19.5628 23.8389 14.0937C24.6075 8.62465 20.7971 3.56797 15.328 2.79934C9.85886 2.0307 4.80218 5.84119 4.03355 11.3103C4.02368 11.3805 4.01456 11.4507 4.00619 11.5209L2.97388 10.1944C2.63469 9.75851 2.00639 9.68015 1.57054 10.0193C1.13469 10.3585 1.05632 10.9868 1.39551 11.4227L3.74048 14.4359C4.15069 14.963 4.90281 15.0745 5.4483 14.6891L8.61937 12.4485C9.07042 12.1297 9.1777 11.5057 8.859 11.0547C8.54029 10.6036 7.91628 10.4964 7.46523 10.8151L5.98009 11.8644C5.98982 11.7727 6.00114 11.6808 6.01409 11.5886Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

14
src/icons/Search.svelte Normal file
View file

@ -0,0 +1,14 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,17 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

15
src/icons/Share.svelte Normal file
View file

@ -0,0 +1,15 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M23 5.5C23 7.98528 20.9853 10 18.5 10C17.0993 10 15.8481 9.36007 15.0228 8.35663L9.87308 10.9315C9.95603 11.2731 10 11.63 10 11.9971C10 12.3661 9.9556 12.7247 9.87184 13.0678L15.0228 15.6433C15.8482 14.6399 17.0993 14 18.5 14C20.9853 14 23 16.0147 23 18.5C23 20.9853 20.9853 23 18.5 23C16.0147 23 14 20.9853 14 18.5C14 18.1319 14.0442 17.7742 14.1276 17.4318L8.97554 14.8558C8.1502 15.8581 6.89973 16.4971 5.5 16.4971C3.01472 16.4971 1 14.4824 1 11.9971C1 9.51185 3.01472 7.49713 5.5 7.49713C6.90161 7.49713 8.15356 8.13793 8.97886 9.14254L14.1275 6.5682C14.0442 6.2258 14 5.86806 14 5.5C14 3.01472 16.0147 1 18.5 1C20.9853 1 23 3.01472 23 5.5ZM16.0029 5.5C16.0029 6.87913 17.1209 7.99713 18.5 7.99713C19.8791 7.99713 20.9971 6.87913 20.9971 5.5C20.9971 4.12087 19.8791 3.00287 18.5 3.00287C17.1209 3.00287 16.0029 4.12087 16.0029 5.5ZM16.0029 18.5C16.0029 19.8791 17.1209 20.9971 18.5 20.9971C19.8791 20.9971 20.9971 19.8791 20.9971 18.5C20.9971 17.1209 19.8791 16.0029 18.5 16.0029C17.1209 16.0029 16.0029 17.1209 16.0029 18.5ZM5.5 14.4943C4.12087 14.4943 3.00287 13.3763 3.00287 11.9971C3.00287 10.618 4.12087 9.5 5.5 9.5C6.87913 9.5 7.99713 10.618 7.99713 11.9971C7.99713 13.3763 6.87913 14.4943 5.5 14.4943Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

25
src/icons/Spinner.svelte Normal file
View file

@ -0,0 +1,25 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>

After

Width:  |  Height:  |  Size: 696 B

16
src/icons/Stream.svelte Normal file
View file

@ -0,0 +1,16 @@
<svg
{...$$restProps}
width={$$props.size}
height={$$props.size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 15.1001C3.96089 15.2961 4.84294 15.7703 5.53638 16.4637C6.22982 17.1572 6.70403 18.0392 6.9 19.0001M3 19H3.01M3 11.0498C5.03079 11.2757 6.92428 12.1859 8.36911 13.6307C9.81395 15.0755 10.7241 16.969 10.95 18.9998M15 19H17.8C18.9201 19 19.4802 19 19.908 18.782C20.2843 18.5903 20.5903 18.2843 20.782 17.908C21 17.4802 21 16.9201 21 15.8V8.2C21 7.0799 21 6.51984 20.782 6.09202C20.5903 5.71569 20.2843 5.40973 19.908 5.21799C19.4802 5 18.9201 5 17.8 5H5C3.89543 5 3 5.89543 3 7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

29
src/icons/index.ts Normal file
View file

@ -0,0 +1,29 @@
export { default as Add } from './Add.svelte'
export { default as CheckboxChecked } from './CheckboxChecked.svelte'
export { default as CheckboxDisabled } from './CheckboxDisabled.svelte'
export { default as CheckboxIndeterminate } from './CheckboxIndeterminate.svelte'
export { default as CheckboxUnchecked } from './CheckboxUnchecked.svelte'
export { default as Check } from './Check.svelte'
export { default as Clear } from './Clear.svelte'
export { default as Close } from './Close.svelte'
export { default as Copy } from './Copy.svelte'
export { default as CreatePlaylist } from './CreatePlaylist.svelte'
export { default as DarkMode } from './DarkMode.svelte'
export { default as DeselectAll } from './DeselectAll.svelte'
export { default as Download } from './Download.svelte'
export { default as Edit } from './Edit.svelte'
export { default as Expand } from './Expand.svelte'
export { default as ExternalLink } from './ExternalLink.svelte'
export { default as Feed } from './Feed.svelte'
export { default as GitHub } from './GitHub.svelte'
export { default as Guide } from './Guide.svelte'
export { default as LightMode } from './LightMode.svelte'
export { default as Link } from './Link.svelte'
export { default as Menu } from './Menu.svelte'
export { default as Remove } from './Remove.svelte'
export { default as Reset } from './Reset.svelte'
export { default as Search } from './Search.svelte'
export { default as SelectAll } from './SelectAll.svelte'
export { default as Share } from './Share.svelte'
export { default as Stream } from './Stream.svelte'
export { default as Spinner } from './Spinner.svelte'

View file

@ -1,22 +0,0 @@
import { Storage } from '@freearhey/core'
import { ApiClient } from './utils/apiClient.js'
async function main() {
const client = new ApiClient({ storage: new Storage('src/data') })
const requests = [
client.download('blocklist.json'),
client.download('categories.json'),
client.download('channels.json'),
client.download('streams.json'),
client.download('guides.json'),
client.download('countries.json'),
client.download('languages.json'),
client.download('regions.json'),
client.download('subdivisions.json')
]
await Promise.all(requests)
}
main()

View file

@ -0,0 +1,50 @@
import type { BlocklistRecordData, BlocklistRecordSerializedData } from '~/types/blocklistRecord'
export class BlocklistRecord {
channelId: string
reason: string
refUrl: string
constructor(data?: BlocklistRecordData) {
if (!data) return
this.channelId = data.channel
this.reason = data.reason
this.refUrl = data.ref
}
getRefLabel(): string {
let refLabel = ''
const isIssue = /issues|pull/.test(this.refUrl)
const isAttachment = /github\.zendesk\.com\/attachments\/token/.test(this.refUrl)
if (isIssue) {
const parts = this.refUrl.split('/')
const issueId = parts.pop()
refLabel = `#${issueId}`
} else if (isAttachment) {
const [, filename] = this.refUrl.match(/\?name=(.*)/) || [null, undefined]
refLabel = filename
} else {
refLabel = this.refUrl.split('/').pop()
}
return refLabel
}
serialize(): BlocklistRecordSerializedData {
return {
channelId: this.channelId,
reason: this.reason,
refUrl: this.refUrl
}
}
deserialize(data: BlocklistRecordSerializedData): this {
this.channelId = data.channelId
this.reason = data.reason
this.refUrl = data.refUrl
return this
}
}

195
src/models/broadcastArea.ts Normal file
View file

@ -0,0 +1,195 @@
import type { BroadcastAreaData, BroadcastAreaSerializedData } from '~/types/broadcastArea'
import type { SubdivisionSerializedData } from '~/types/subdivision'
import type { CountrySerializedData } from '~/types/country'
import type { RegionSerializedData } from '~/types/region'
import { type Dictionary, Collection } from '@freearhey/core/browser'
import { Region, Country, Subdivision } from './'
export class BroadcastArea {
code: string
name?: string
countries?: Collection
subdivisions?: Collection
regions?: Collection
constructor(data?: BroadcastAreaData) {
if (!data) return
this.code = data.code
}
withName(
countriesKeyByCode: Dictionary,
subdivisionsKeyByCode: Dictionary,
regionsKeyByCode: Dictionary
): this {
const [type, code] = this.code.split('/')
switch (type) {
case 's': {
const subdivision: Subdivision = subdivisionsKeyByCode.get(code)
if (subdivision) this.name = subdivision.name
break
}
case 'c': {
const country: Country = countriesKeyByCode.get(code)
if (country) this.name = country.name
break
}
case 'r': {
const region: Region = regionsKeyByCode.get(code)
if (region) this.name = region.name
break
}
}
return this
}
withLocations(
countriesKeyByCode: Dictionary,
subdivisionsKeyByCode: Dictionary,
regionsKeyByCode: Dictionary,
regions: Collection
): this {
const [type, code] = this.code.split('/')
let _countries = new Collection()
let _regions = new Collection()
let _subdivisions = new Collection()
regions = regions.filter((region: Region) => region.code !== 'INT')
switch (type) {
case 's': {
const subdivision: Subdivision = subdivisionsKeyByCode.get(code)
if (!subdivision) break
_subdivisions.add(subdivision)
const country: Country = countriesKeyByCode.get(subdivision.countryCode)
if (!country) break
_countries.add(country)
const countryRegions = regions.filter((region: Region) =>
region.countryCodes.includes(country.code)
)
countryRegions.forEach((region: Region) => {
_regions.add(region)
})
break
}
case 'c': {
const country = countriesKeyByCode.get(code)
if (!country) break
_countries.add(country)
const countryRegions = regions.filter((region: Region) =>
region.countryCodes.includes(country.code)
)
countryRegions.forEach((region: Region) => {
_regions.add(region)
})
break
}
case 'r': {
const region: Region = regionsKeyByCode.get(code)
if (!region) break
_regions.add(region)
break
}
}
this.countries = _countries.uniqBy((country: Country) => country.code)
this.regions = _regions.uniqBy((region: Region) => region.code)
this.subdivisions = _subdivisions.uniqBy((subdivision: Subdivision) => subdivision.code)
return this
}
getName(): string {
return this.name || ''
}
getCountries(): Collection {
if (!this.countries) return new Collection()
return this.countries
}
getRegions(): Collection {
if (!this.regions) return new Collection()
return this.regions
}
getSubdivisions(): Collection {
if (!this.subdivisions) return new Collection()
return this.subdivisions
}
getLocationCodes(): Collection {
let locationCodes = new Collection()
this.getCountries().forEach((country: Country) => {
locationCodes.add(country.code)
})
this.getRegions().forEach((region: Region) => {
locationCodes.add(region.code)
})
this.getSubdivisions().forEach((subdivision: Subdivision) => {
locationCodes.add(subdivision.code)
})
return locationCodes
}
getLocationNames(): Collection {
let locationNames = new Collection()
this.getCountries().forEach((country: Country) => {
locationNames.add(country.name)
})
this.getRegions().forEach((region: Region) => {
locationNames.add(region.name)
})
this.getSubdivisions().forEach((subdivision: Subdivision) => {
locationNames.add(subdivision.name)
})
return locationNames
}
serialize(): BroadcastAreaSerializedData {
return {
code: this.code,
name: this.getName(),
countries: this.getCountries()
.map((country: Country) => country.serialize())
.all(),
subdivisions: this.getSubdivisions()
.map((subdivision: Subdivision) => subdivision.serialize())
.all(),
regions: this.getRegions()
.map((region: Region) => region.serialize())
.all()
}
}
deserialize(data: BroadcastAreaSerializedData): this {
this.code = data.code
this.name = data.name
this.countries = new Collection(data.countries).map((data: CountrySerializedData) =>
new Country().deserialize(data)
)
this.subdivisions = new Collection(data.subdivisions).map((data: SubdivisionSerializedData) =>
new Subdivision().deserialize(data)
)
this.regions = new Collection(data.regions).map((data: RegionSerializedData) =>
new Region().deserialize(data)
)
return this
}
}

27
src/models/category.ts Normal file
View file

@ -0,0 +1,27 @@
import type { CategoryData, CategorySerializedData } from '~/types/category'
export class Category {
id: string
name: string
constructor(data?: CategoryData) {
if (!data) return
this.id = data.id
this.name = data.name
}
serialize(): CategorySerializedData {
return {
id: this.id,
name: this.name
}
}
deserialize(data: CategorySerializedData): this {
this.id = data.id
this.name = data.name
return this
}
}

View file

@ -1,56 +0,0 @@
export class Channel {
constructor(data) {
const _streams = Array.isArray(data.streams) ? data.streams : []
const _guides = Array.isArray(data.guides) ? data.guides : []
const _blocklistRecords = Array.isArray(data.blocklistRecords) ? data.blocklistRecords : []
this.id = data.id
this.name = data.name
this.alt_names = this.alt_name = data.altNames
this.network = data.network
this.owners = this.owner = data.owners
this.city = data.city
this.country = [data.country?.code, data.country?.name].filter(Boolean)
this.subdivision = data.subdivision?.code || null
this.languages = this.language = [
...data.languages.map(language => language.code),
...data.languages.map(language => language.name)
]
this.categories = this.category = data.categories.map(category => category.name)
this.broadcast_area = [
...data.broadcastArea.map(area => `${area.type}/${area.code}`).filter(Boolean),
...data.broadcastArea.map(area => area.name).filter(Boolean),
...data.regionCountries.map(country => country.code).filter(Boolean),
...data.regionCountries.map(country => country.name).filter(Boolean)
]
this.is_nsfw = data.isNSFW
this.launched = data.launched
this.closed = data.closed
this.is_closed = !!data.closed || !!data.replacedBy
this.replaced_by = data.replacedBy
this.website = data.website
this.logo = data.logo
this.streams = _streams.length
this.guides = _guides.length
this.is_blocked = _blocklistRecords.length > 0
this._hasUniqueName = data.hasUniqueName
this._displayName = data.hasUniqueName ? data.name : `${data.name} (${data.country?.name})`
this._country = data.country
this._subdivision = data.subdivision || null
this._languages = data.languages
this._categories = data.categories
this._broadcastArea = data.broadcastArea
this._streams = _streams
this._guides = _guides
this._blocklistRecords = _blocklistRecords
this._guideNames = _guides.map(guide => guide.site_name).filter(Boolean)
this._streamUrls = _streams.map(stream => stream.url).filter(Boolean)
}
toObject() {
const { ...object } = this
return object
}
}

537
src/models/channel.ts Normal file
View file

@ -0,0 +1,537 @@
import type { ChannelSearchable, ChannelSerializedData, ChannelData } from '../types/channel'
import type { BlocklistRecordSerializedData } from '~/types/blocklistRecord'
import type { HTMLPreviewField } from '../types/htmlPreviewField'
import type { CategorySerializedData } from '~/types/category'
import type { FeedSerializedData } from '~/types/feed'
import { Collection, type Dictionary } from '@freearhey/core/browser'
import dayjs, { type Dayjs } from 'dayjs'
import {
BlocklistRecord,
BroadcastArea,
Subdivision,
Category,
Language,
Country,
Stream,
Guide,
Feed
} from '.'
export class Channel {
id: string
name: string
altNames: Collection = new Collection()
networkName?: string
ownerNames: Collection = new Collection()
countryCode: string
country?: Country
subdivisionCode?: string
subdivision?: Subdivision
cityName?: string
categoryIds: Collection = new Collection()
categories: Collection = new Collection()
isNSFW: boolean
launchedDateString?: string
launchedDate?: Dayjs
closedDateString?: string
closedDate?: Dayjs
replacedByStreamId?: string
replacedByChannelId?: string
websiteUrl?: string
logoUrl: string
blocklistRecords: Collection = new Collection()
feeds: Collection = new Collection()
hasUniqueName: boolean = true
constructor(data?: ChannelData) {
if (!data) return
this.id = data.id
this.name = data.name
this.altNames = new Collection(data.alt_names)
this.networkName = data.network
this.ownerNames = new Collection(data.owners)
this.countryCode = data.country
this.subdivisionCode = data.subdivision
this.cityName = data.city
this.categoryIds = new Collection(data.categories)
this.isNSFW = data.is_nsfw
this.launchedDateString = data.launched
this.launchedDate = data.launched ? dayjs(data.launched) : undefined
this.closedDateString = data.closed
this.closedDate = data.closed ? dayjs(data.closed) : undefined
this.replacedByStreamId = data.replaced_by || undefined
const [replacedByChannelId] = data.replaced_by ? data.replaced_by.split('@') : [undefined]
this.replacedByChannelId = replacedByChannelId
this.websiteUrl = data.website
this.logoUrl = data.logo
}
withCountry(countriesKeyByCode: Dictionary): this {
this.country = countriesKeyByCode.get(this.countryCode)
return this
}
withSubdivision(subdivisionsKeyByCode: Dictionary): this {
if (!this.subdivisionCode) return this
this.subdivision = subdivisionsKeyByCode.get(this.subdivisionCode)
return this
}
withCategories(categoriesKeyById: Dictionary): this {
this.categories = this.categoryIds.map((id: string) => categoriesKeyById.get(id))
return this
}
withBlocklistRecords(blocklistGroupedByChannelId: Dictionary): this {
this.blocklistRecords = new Collection(blocklistGroupedByChannelId.get(this.id))
return this
}
withFeeds(feedsGroupedByChannelId: Dictionary): this {
this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
return this
}
setHasUniqueName(channelsGroupedByName: Dictionary): this {
this.hasUniqueName = new Collection(channelsGroupedByName.get(this.name)).count() === 1
return this
}
getUniqueName(): string {
if (this.hasUniqueName) return this.name
if (!this.country) return this.name
return `${this.name} (${this.country.name})`
}
getCategories(): Collection {
if (!this.categories) return new Collection()
return this.categories
}
getStreams(): Collection {
let streams = new Collection()
this.getFeeds().forEach((feed: Feed) => {
streams = streams.concat(feed.getStreams())
})
return streams
}
getGuides(): Collection {
let guides = new Collection()
this.getFeeds().forEach((feed: Feed) => {
guides = guides.concat(feed.getGuides())
})
return guides
}
getLanguages(): Collection {
let languages = new Collection()
this.getFeeds().forEach((feed: Feed) => {
languages = languages.concat(feed.getLanguages())
})
return languages.uniqBy((language: Language) => language.code)
}
getLanguageCodes(): Collection {
return this.getLanguages().map((language: Language) => language.code)
}
getLanguageNames(): Collection {
return this.getLanguages().map((language: Language) => language.name)
}
getBroadcastAreaCodes(): Collection {
let broadcastAreaCodes = new Collection()
this.getFeeds().forEach((feed: Feed) => {
broadcastAreaCodes = broadcastAreaCodes.concat(feed.broadcastAreaCodes)
})
return broadcastAreaCodes.uniq()
}
hasGuides(): boolean {
return this.getGuides().notEmpty()
}
hasFeeds(): boolean {
return this.getFeeds().notEmpty()
}
hasStreams(): boolean {
return this.getStreams().notEmpty()
}
getDisplayName(): string {
return this.name
}
getPagePath(): string {
const [channelSlug, countryCode] = this.id.split('.')
if (!channelSlug || !countryCode) return ''
return `/channels/${countryCode}/${channelSlug}`
}
getPageUrl(): string {
const [channelSlug, countryCode] = this.id.split('.') || [null, null]
if (!channelSlug || !countryCode || typeof window === 'undefined') return ''
return `${window.location.protocol}//${window.location.host}/channels/${countryCode}/${channelSlug}`
}
isClosed(): boolean {
return !!this.closedDateString || !!this.replacedByStreamId
}
isBlocked(): boolean {
return this.blocklistRecords ? this.blocklistRecords.notEmpty() : false
}
getCountryName(): string {
return this.country ? this.country.name : ''
}
getGuideSiteNames(): Collection {
return this.getGuides().map((guide: Guide) => guide.siteName)
}
getStreamUrls(): Collection {
return this.getStreams().map((stream: Stream) => stream.url)
}
getFeeds(): Collection {
if (!this.feeds) return new Collection()
return this.feeds
}
getBroadcastLocationCodes(): Collection {
let broadcastLocationCodes = new Collection()
this.getFeeds().forEach((feed: Feed) => {
broadcastLocationCodes = broadcastLocationCodes.concat(feed.getBroadcastLocationCodes())
})
return broadcastLocationCodes.uniq()
}
getBroadcastLocationNames(): Collection {
let broadcastLocationNames = new Collection()
this.getFeeds().forEach((feed: Feed) => {
broadcastLocationNames = broadcastLocationNames.concat(feed.getBroadcastLocationNames())
})
return broadcastLocationNames.uniq()
}
getVideoFormats(): Collection {
let videoFormats = new Collection()
this.getFeeds().forEach((feed: Feed) => {
videoFormats.add(feed.videoFormat)
})
return videoFormats.uniq()
}
getTimezoneIds(): Collection {
let timezoneIds = new Collection()
this.getFeeds().forEach((feed: Feed) => {
timezoneIds = timezoneIds.concat(feed.timezoneIds)
})
return timezoneIds.uniq()
}
getBroadcastArea(): Collection {
let broadcastArea = new Collection()
this.getFeeds().forEach((feed: Feed) => {
broadcastArea = broadcastArea.concat(feed.getBroadcastArea())
})
return broadcastArea.uniqBy((broadcastArea: BroadcastArea) => broadcastArea.code)
}
getSearchable(): ChannelSearchable {
return {
id: this.id,
name: this.name,
alt_names: this.altNames.all(),
alt_name: this.altNames.all(),
network: this.networkName,
owner: this.ownerNames.all(),
owners: this.ownerNames.all(),
country: this.countryCode,
subdivision: this.subdivisionCode,
city: this.cityName,
category: this.categoryIds.all(),
categories: this.categoryIds.all(),
launched: this.launchedDateString,
closed: this.closedDateString,
replaced_by: this.replacedByStreamId,
website: this.websiteUrl,
is_nsfw: this.isNSFW,
is_closed: this.isClosed(),
is_blocked: this.isBlocked(),
feeds: this.getFeeds().count(),
streams: this.getStreams().count(),
guides: this.getGuides().count(),
language: this.getLanguageCodes().all(),
languages: this.getLanguageCodes().all(),
broadcast_area: this.getBroadcastAreaCodes().all(),
video_format: this.getVideoFormats().all(),
video_formats: this.getVideoFormats().all(),
timezone: this.getTimezoneIds().all(),
timezones: this.getTimezoneIds().all(),
_languageNames: this.getLanguageNames().all(),
_broadcastLocationCodes: this.getBroadcastLocationCodes().all(),
_broadcastLocationNames: this.getBroadcastLocationNames().all(),
_countryName: this.getCountryName(),
_guideSiteNames: this.getGuideSiteNames().all(),
_streamUrls: this.getStreamUrls().all()
}
}
serialize(props = { withFeeds: true }): ChannelSerializedData {
return {
id: this.id,
name: this.name,
altNames: this.altNames.all(),
networkName: this.networkName,
ownerNames: this.ownerNames.all(),
countryCode: this.countryCode,
country: this.country ? this.country.serialize() : null,
subdivisionCode: this.subdivisionCode,
subdivision: this.subdivision ? this.subdivision.serialize() : null,
cityName: this.cityName,
categoryIds: this.categoryIds.all(),
categories: this.categories.map((category: Category) => category.serialize()).all(),
isNSFW: this.isNSFW,
launchedDateString: this.launchedDateString,
launchedDate: this.launchedDate ? this.launchedDate.toJSON() : null,
closedDateString: this.closedDateString,
closedDate: this.closedDate ? this.closedDate.toJSON() : null,
replacedByStreamId: this.replacedByStreamId,
replacedByChannelId: this.replacedByChannelId,
websiteUrl: this.websiteUrl,
logoUrl: this.logoUrl,
blocklistRecords: this.blocklistRecords
.map((blocklistRecord: BlocklistRecord) => blocklistRecord.serialize())
.all(),
feeds: props.withFeeds
? this.getFeeds()
.map((feed: Feed) => feed.serialize())
.all()
: [],
hasUniqueName: this.hasUniqueName
}
}
deserialize(data: ChannelSerializedData): this {
this.id = data.id || ''
this.name = data.name
this.altNames = new Collection(data.altNames)
this.networkName = data.networkName
this.ownerNames = new Collection(data.ownerNames)
this.countryCode = data.countryCode
this.country = new Country().deserialize(data.country)
this.subdivisionCode = data.subdivisionCode
this.cityName = data.cityName
this.categoryIds = new Collection(data.categoryIds)
this.categories = new Collection(data.categories).map((data: CategorySerializedData) =>
new Category().deserialize(data)
)
this.isNSFW = data.isNSFW
this.launchedDateString = data.launchedDateString
this.launchedDate = data.launchedDate ? dayjs(data.launchedDate) : undefined
this.closedDateString = data.closedDateString
this.closedDate = data.closedDate ? dayjs(data.closedDate) : undefined
this.replacedByStreamId = data.replacedByStreamId
this.replacedByChannelId = data.replacedByChannelId
this.websiteUrl = data.websiteUrl
this.logoUrl = data.logoUrl
this.blocklistRecords = new Collection(data.blocklistRecords).map(
(data: BlocklistRecordSerializedData) => new BlocklistRecord().deserialize(data)
)
this.feeds = new Collection(data.feeds).map((data: FeedSerializedData) =>
new Feed().deserialize(data)
)
this.hasUniqueName = data.hasUniqueName
return this
}
getFieldset(): HTMLPreviewField[] {
return [
{
name: 'logo',
type: 'image',
value: { src: this.logoUrl, alt: `${this.name} logo`, title: this.logoUrl }
},
{ name: 'id', type: 'string', value: this.id, title: this.id },
{ name: 'name', type: 'string', value: this.name, title: this.name },
{ name: 'alt_names', type: 'string[]', value: this.altNames.all() },
{
name: 'network',
type: 'link',
value: this.networkName
? { label: this.networkName, query: `network:${normalize(this.networkName)}` }
: null
},
{
name: 'owners',
type: 'link[]',
value: this.ownerNames
.map((name: string) => ({
label: name,
query: `owner:${normalize(name)}`
}))
.all()
},
{
name: 'country',
type: 'link',
value: this.country
? { label: this.country.name, query: `country:${this.country.code}` }
: null
},
{
name: 'subdivision',
type: 'link',
value: this.subdivision
? { label: this.subdivision.name, query: `subdivision:${this.subdivision.code}` }
: null
},
{
name: 'city',
type: 'link',
value: this.cityName ? { label: this.cityName, query: `city:${this.cityName}` } : null
},
{
name: 'broadcast_area',
type: 'link[]',
value: this.getBroadcastArea()
.map((broadcastArea: BroadcastArea) => ({
label: broadcastArea.getName(),
query: `broadcast_area:${broadcastArea.code}`
}))
.all()
},
{
name: 'timezones',
type: 'link[]',
value: this.getTimezoneIds()
.map((id: string) => ({
label: id,
query: `timezone:${id}`
}))
.all()
},
{
name: 'languages',
type: 'link[]',
value: this.getLanguages()
.map((language: Language) => ({
label: language.name,
query: `language:${language.code}`
}))
.all()
},
{
name: 'categories',
type: 'link[]',
value: this.categories
.map((category: Category) => ({
label: category.name,
query: `category:${category.id}`
}))
.all()
},
{
name: 'is_nsfw',
type: 'link',
value: { label: this.isNSFW.toString(), query: `is_nsfw:${this.isNSFW.toString()}` }
},
{
name: 'video_formats',
type: 'link[]',
value: this.getVideoFormats()
.map((format: string) => ({
label: format,
query: `video_format:${format}`
}))
.all()
},
{
name: 'launched',
type: 'string',
value: this.launchedDate ? this.launchedDate.format('D MMMM YYYY') : null,
title: this.launchedDateString
},
{
name: 'closed',
type: 'string',
value: this.closedDate ? this.closedDate.format('D MMMM YYYY') : null,
title: this.closedDateString
},
{
name: 'replaced_by',
type: 'link',
value: this.replacedByStreamId
? {
label: this.replacedByStreamId,
query: `id:${this.replacedByChannelId.replace('.', '\\.')}`
}
: null
},
{
name: 'website',
type: 'external_link',
value: this.websiteUrl
? { href: this.websiteUrl, title: this.websiteUrl, label: this.websiteUrl }
: null
}
].filter((field: HTMLPreviewField) =>
Array.isArray(field.value) ? field.value.length : field.value
)
}
getStructuredData() {
return {
'@context': 'https://schema.org/',
'@type': 'TelevisionChannel',
image: this.logoUrl,
identifier: this.id,
name: this.name,
alternateName: this.altNames.map((name: string) => ({ '@value': name })),
genre: this.categories.map((category: Category) => ({ '@value': category.name })),
sameAs: this.websiteUrl
}
}
}
function normalize(value: string) {
value = value.includes(' ') ? `"${value}"` : value
return encodeURIComponent(value)
}

Some files were not shown because too many files have changed in this diff Show more