Compare commits

...

11 Commits

Author SHA1 Message Date
blade 8e5a1126a6 Deploy local stack fixes and search fallback 2026-03-10 23:12:32 -04:00
dragongoose ddee63ebbe Add new instance privadency.com (amazing website) #148 2025-09-30 19:56:24 -04:00
dragongoose ba0e811149 Add canine.tools (awesome url!) #131 2025-09-30 19:52:48 -04:00
dragongoose 67c19e2e53 Format all files to spec 2025-09-30 19:46:35 -04:00
dragongoose 55fc96447e Fix title related issues 2025-09-30 19:45:44 -04:00
dragongoose f8176ccc3a Remove console.log debug 2025-09-30 19:45:28 -04:00
dragongoose 4494681fd3 Lint 2025-09-30 17:34:40 -04:00
dragongoose e3afaaf386 Update locales 2025-09-30 15:16:09 -04:00
dragongoose 9dc6de544f Fix #133 2025-09-30 15:15:55 -04:00
dragongoose 9bdb9c1822 Merge branch 'Smarty1098-master' #144 2025-09-30 14:39:54 -04:00
Samuel Martins 919675874c Fix Following section
Solves few issues of the "Following" section in the homepage:
* There was some space between groups of live streams loaded in
  different bulk requests.
* When the scrollbar reached the end it would load always the same
  streamers multiple times.
* If the scrollbar does not appear (too few live streamers in the first
  bulk request) it is impossible to trigger next bulk request.
2024-12-28 15:09:52 +01:00
17 changed files with 679 additions and 70 deletions
+2
View File
@@ -97,6 +97,8 @@ If you host a SafeTwitch instance and would like it to be listed in the readme,
| [safetwitch.4o1x5.dev/](https://safetwitch.4o1x5.dev/) | 🇭🇺 | [See 4o1x5's site for more info](https://4o1x5.dev/privacy-policy/) | ❌ | | [safetwitch.4o1x5.dev/](https://safetwitch.4o1x5.dev/) | 🇭🇺 | [See 4o1x5's site for more info](https://4o1x5.dev/privacy-policy/) | ❌ |
| [safetwitch.adminforge.de](https://safetwitch.adminforge.de) | 🇩🇪 | Hosted on Hetzner by adminForge.de | ❌ | | [safetwitch.adminforge.de](https://safetwitch.adminforge.de) | 🇩🇪 | Hosted on Hetzner by adminForge.de | ❌ |
| [twitch.blitzw.in](https://twitch.blitzw.in) | 🇩🇰 | hosted by Nune at blitzw.in. Cha-cha-cha! | ❌ | | [twitch.blitzw.in](https://twitch.blitzw.in) | 🇩🇰 | hosted by Nune at blitzw.in. Cha-cha-cha! | ❌ |
| [safetwitch.canine.tools](https://safetwitch.canine.tools/) | 🇺🇸 | United States, 5Gbit #131 | ❌ |
| [safetwitch.privadency.com](https://safetwitch.privadency.com) | 🇩🇪 | #148 | ❌ |
### Onion ### Onion
+2 -2
View File
@@ -3,7 +3,7 @@
# 2) nginx stage to serve frontend assets # 2) nginx stage to serve frontend assets
# Name the node stage "builder" # Name the node stage "builder"
FROM docker.io/node:16 AS builder FROM docker.io/node:22 AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -23,7 +23,7 @@ COPY ./ .
RUN ls RUN ls
# install node modules and build assets # install node modules and build assets
RUN npm i && npm run build RUN npm i && npm run build-only
# nginx state for serving content # nginx state for serving content
FROM docker.io/nginx:alpine FROM docker.io/nginx:alpine
+103 -10
View File
@@ -1,19 +1,49 @@
version: "3.7" name: safetwitch
services: services:
safetwitch-frontend: safetwitch-frontend:
container_name: safetwitch-frontend container_name: safetwitch-frontend
hostname: safetwitch-frontend hostname: safetwitch-frontend
restart: always restart: always
image: codeberg.org/safetwitch/safetwitch:latest image: safetwitch-local:latest
build:
context: "../"
dockerfile: "./docker/Dockerfile"
ports: ports:
- "127.0.0.1:8280:8280" - "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280"
environment: environment:
- SAFETWITCH_BACKEND_DOMAIN=changeme - SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services}
- SAFETWITCH_INSTANCE_DOMAIN=changeme - SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services}
- SAFETWITCH_HTTPS=true - SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true}
- SAFETWITCH_DEFAULT_LOCALE=en - SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en}
- SAFETWITCH_FALLBACK_LOCALE=en - SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en}
- SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark}
labels:
- "traefik.enable=true"
- "traefik.http.routers.safetwitch.rule=Host(`safetwitch.darkness.services`)"
- "traefik.http.routers.safetwitch.entrypoints=https"
- "traefik.http.routers.safetwitch.tls.certresolver=myresolver"
- "traefik.http.services.safetwitch.loadbalancer.server.port=8280"
safetwitch-frontend-tor:
container_name: safetwitch-frontend-tor
hostname: safetwitch-frontend-tor
restart: always
image: safetwitch-local:latest
ports:
- "127.0.0.1:${SAFETWITCH_FRONTEND_TOR_PORT:-8281}:8280"
environment:
- SAFETWITCH_BACKEND_DOMAIN=api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
- SAFETWITCH_INSTANCE_DOMAIN=safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
- SAFETWITCH_HTTPS=false
- SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en}
- SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en}
- SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark}
labels:
- "traefik.enable=true"
- "traefik.http.services.safetwitch-tor.loadbalancer.server.port=8280"
- "traefik.http.routers.safetwitch-tor.entrypoints=tor"
- "traefik.http.routers.safetwitch-tor.rule=Host(`safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)"
safetwitch-backend: safetwitch-backend:
container_name: safetwitch-backend container_name: safetwitch-backend
@@ -26,7 +56,70 @@ services:
restart: always restart: always
image: codeberg.org/safetwitch/safetwitch-backend:latest image: codeberg.org/safetwitch/safetwitch-backend:latest
ports: ports:
- "127.0.0.1:7100:7000" - "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000"
environment: environment:
- PORT=7000 - PORT=7000
- URL=changeme - URL=${SAFETWITCH_BACKEND_URL:-https://api.safetwitch.darkness.services}
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch.rule=Host(`api.safetwitch.darkness.services`)"
- "traefik.http.routers.apisafetwitch.entrypoints=https"
- "traefik.http.routers.apisafetwitch.tls.certresolver=myresolver"
- "traefik.http.services.apisafetwitch.loadbalancer.server.port=7000"
safetwitch-backend-tor:
container_name: safetwitch-backend-tor
hostname: safetwitch-backend-tor
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: always
image: codeberg.org/safetwitch/safetwitch-backend:latest
ports:
- "127.0.0.1:${SAFETWITCH_BACKEND_TOR_PORT:-7101}:7000"
environment:
- PORT=7000
- URL=http://api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)"
- "traefik.http.routers.apisafetwitch-tor.entrypoints=tor"
- "traefik.http.services.apisafetwitch-tor.loadbalancer.server.port=7000"
safetwitch-search-fallback:
container_name: safetwitch-search-fallback
hostname: safetwitch-search-fallback
restart: always
image: docker.io/node:22-alpine
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp
command: ["node", "/app/server.mjs"]
volumes:
- "./search-fallback/server.mjs:/app/server.mjs:ro"
environment:
- PORT=7171
- BACKEND_BASE_URL=http://safetwitch-backend:7000
labels:
- "traefik.enable=true"
- "traefik.http.routers.apisafetwitch-search.rule=Host(`api.safetwitch.darkness.services`) && PathPrefix(`/api/search`)"
- "traefik.http.routers.apisafetwitch-search.entrypoints=https"
- "traefik.http.routers.apisafetwitch-search.tls.certresolver=myresolver"
- "traefik.http.routers.apisafetwitch-search.priority=100"
- "traefik.http.routers.apisafetwitch-search.service=apisafetwitch-search"
- "traefik.http.routers.apisafetwitch-search-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`) && PathPrefix(`/api/search`)"
- "traefik.http.routers.apisafetwitch-search-tor.entrypoints=tor"
- "traefik.http.routers.apisafetwitch-search-tor.priority=100"
- "traefik.http.routers.apisafetwitch-search-tor.service=apisafetwitch-search"
- "traefik.http.services.apisafetwitch-search.loadbalancer.server.port=7171"
networks:
default:
name: ${SAFETWITCH_DOCKER_NETWORK:-npm_default}
external: ${SAFETWITCH_DOCKER_NETWORK_EXTERNAL:-true}
+18 -1
View File
@@ -4,9 +4,26 @@ http {
server { server {
listen 8280; listen 8280;
access_log /dev/null; access_log /dev/null;
error_log /dev/null; error_log /dev/null;
# Forward backend API/proxy calls so absolute frontend-host URLs in
# manifests (e.g. /proxy/stream/sub/...) continue to work.
location /api/ {
proxy_pass http://safetwitch-backend:7000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /proxy/ {
proxy_pass http://safetwitch-backend:7000/proxy/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
root /app; root /app;
+196
View File
@@ -0,0 +1,196 @@
import http from 'node:http'
const port = Number(process.env.PORT || 7171)
const backendBaseUrl = (process.env.BACKEND_BASE_URL || 'http://safetwitch-backend:7000').replace(
/\/+$/,
''
)
const jsonHeaders = {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*'
}
const emptyResult = () => ({
channels: [],
categories: [],
relatedChannels: [],
channelsWithTag: []
})
const hasAnyResults = (data) =>
!!data &&
(data.channels?.length ||
data.categories?.length ||
data.relatedChannels?.length ||
data.channelsWithTag?.length)
const normalize = (value) =>
String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\biv\b/g, '4')
.replace(/\biii\b/g, '3')
.replace(/\bii\b/g, '2')
.replace(/\bi\b/g, '1')
.replace(/\s+/g, ' ')
.trim()
const withRomanNumerals = (value) =>
String(value || '')
.replace(/\b4\b/gi, 'IV')
.replace(/\b3\b/gi, 'III')
.replace(/\b2\b/gi, 'II')
.replace(/\b1\b/gi, 'I')
const withArabicNumerals = (value) =>
String(value || '')
.replace(/\biv\b/gi, '4')
.replace(/\biii\b/gi, '3')
.replace(/\bii\b/gi, '2')
.replace(/\bi\b/gi, '1')
const mapDiscoverCategory = (category) => ({
name: category?.name || '',
displayName: category?.displayName || category?.name || '',
viewers: Number(category?.viewers || 0),
tags: Array.isArray(category?.tags) ? category.tags : [],
createdAt: category?.createdAt,
image: category?.image || ''
})
const fetchBackend = async (path) => {
const response = await fetch(`${backendBaseUrl}${path}`, {
redirect: 'follow',
headers: {
Accept: 'application/json'
}
})
if (!response.ok) {
throw new Error(`Backend request failed (${response.status}) for ${path}`)
}
return response.json()
}
const buildFallback = async (query) => {
const result = emptyResult()
try {
const channelResp = await fetchBackend(`/api/users/${encodeURIComponent(query)}`)
if (channelResp?.status === 'ok' && channelResp.data) {
result.channels.push(channelResp.data)
}
} catch {
// Ignore fallback channel lookup failures.
}
try {
const discoverResp = await fetchBackend('/api/discover')
const categories = Array.isArray(discoverResp?.data) ? discoverResp.data : []
const normalizedQuery = normalize(query)
result.categories = categories
.filter((category) => {
const displayName = normalize(category?.displayName)
const name = normalize(category?.name)
return (
displayName.includes(normalizedQuery) ||
normalizedQuery.includes(displayName) ||
name.includes(normalizedQuery) ||
normalizedQuery.includes(name)
)
})
.slice(0, 24)
} catch {
// Ignore fallback category lookup failures.
}
const categoryCandidates = [...new Set([query, withRomanNumerals(query), withArabicNumerals(query)])]
for (const candidate of categoryCandidates) {
const trimmed = candidate.trim()
if (!trimmed) continue
try {
const categoryResp = await fetchBackend(`/api/discover/${encodeURIComponent(trimmed)}`)
if (categoryResp?.status !== 'ok' || !categoryResp?.data) {
continue
}
const mapped = mapDiscoverCategory(categoryResp.data)
if (!mapped.name) {
continue
}
const alreadyIncluded = result.categories.some(
(existing) => normalize(existing.name) === normalize(mapped.name)
)
if (!alreadyIncluded) {
result.categories.unshift(mapped)
}
} catch {
// Ignore exact category lookup failures.
}
}
return result
}
const handleSearch = async (query) => {
if (!query) {
return { status: 'ok', data: emptyResult() }
}
try {
const backendSearch = await fetchBackend(`/api/search/?query=${encodeURIComponent(query)}`)
if (backendSearch?.status === 'ok' && hasAnyResults(backendSearch.data)) {
return backendSearch
}
} catch {
// Fallback path below handles backend search failures.
}
const fallbackData = await buildFallback(query)
return { status: 'ok', data: fallbackData }
}
const server = http.createServer(async (req, res) => {
if (!req.url) {
res.writeHead(400, jsonHeaders)
res.end(JSON.stringify({ status: 'error', message: 'Missing request URL' }))
return
}
if (req.method === 'OPTIONS') {
res.writeHead(204, {
...jsonHeaders,
'Access-Control-Allow-Methods': 'GET,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Accept-Language'
})
res.end()
return
}
const url = new URL(req.url, 'http://localhost')
const query = (url.searchParams.get('query') || '').trim()
if (!url.pathname.startsWith('/api/search')) {
res.writeHead(404, jsonHeaders)
res.end(JSON.stringify({ status: 'error', message: 'Not found' }))
return
}
try {
const payload = await handleSearch(query)
res.writeHead(200, jsonHeaders)
res.end(JSON.stringify(payload))
} catch (error) {
res.writeHead(500, jsonHeaders)
res.end(JSON.stringify({ status: 'error', message: String(error) }))
}
})
server.listen(port, '0.0.0.0')
+64 -15
View File
@@ -10,22 +10,55 @@ export const createQualitySelector = (player: any) => {
const qualityLevels: QualityLevelList = player.qualityLevels() const qualityLevels: QualityLevelList = player.qualityLevels()
const MenuButton = videojs.getComponent('MenuButton') const MenuButton = videojs.getComponent('MenuButton')
const MenuItem = videojs.getComponent('MenuItem') const MenuItem = videojs.getComponent('MenuItem')
let formatedQualities: { name: string; index: number; id: string }[] let formatedQualities: { name: string; index: number; id: string; height: number }[] = []
let startupQualityApplied = false
const t = i18n.global.t const t = i18n.global.t
const translatedAuto = t('player.auto')
const autoLabel = translatedAuto === 'player.auto' ? 'Auto' : translatedAuto
const setQuality = (id: string) => { const setAutoQuality = () => {
const found = formatedQualities.find((i) => i.id === id)
for (const quality of qualityLevels.levels_) { for (const quality of qualityLevels.levels_) {
if (quality.id !== id) { // Keep video renditions enabled and skip the audio-only pseudo-level.
quality.enabled = quality.height !== undefined
}
qualityLevels.selectedIndex_ = -1
qualityLevels.trigger({ type: 'change', selectedIndex: -1 })
player.play().catch(() => {
// Ignore autoplay restrictions; user interaction can resume.
})
}
const setQuality = (id?: string) => {
if (!id) {
setAutoQuality()
return
}
const found = formatedQualities.find((i) => i.id === id)
if (!found) {
setAutoQuality()
return
}
for (const quality of qualityLevels.levels_) {
if (quality.height === undefined) {
quality.enabled = false quality.enabled = false
} else { continue
quality.enabled = true
} }
// Keep adaptive bitrate enabled up to the selected quality.
quality.enabled = quality.height <= found.height
} }
qualityLevels.selectedIndex_ = found?.index qualityLevels.selectedIndex_ = found?.index
qualityLevels.trigger({ type: 'change', selectedIndex: found?.index }) qualityLevels.trigger({ type: 'change', selectedIndex: found?.index })
player.play().catch(() => {
// Ignore autoplay restrictions; user interaction can resume.
})
} }
class CustomMenuButton extends MenuButton { class CustomMenuButton extends MenuButton {
@@ -39,6 +72,11 @@ export const createQualitySelector = (player: any) => {
qualitySelectorButton.handleClick = (data) => { qualitySelectorButton.handleClick = (data) => {
const qualityClicked = data.currentTarget.innerText const qualityClicked = data.currentTarget.innerText
if (qualityClicked === autoLabel) {
setAutoQuality()
return
}
const id = formatedQualities.find((i) => i.name === qualityClicked)?.id const id = formatedQualities.find((i) => i.name === qualityClicked)?.id
if (id) { if (id) {
@@ -46,11 +84,6 @@ export const createQualitySelector = (player: any) => {
} }
} }
// set quality on startup
const startingQuality = getSetting('defaultQuality')
const id = formatedQualities.find((i) => i.name.includes(startingQuality))?.id
setQuality(id)
return qualitySelectorButton return qualitySelectorButton
}) })
} }
@@ -66,12 +99,27 @@ export const createQualitySelector = (player: any) => {
player.controlBar.removeChild('CustomMenuButton') player.controlBar.removeChild('CustomMenuButton')
player.controlBar.addChild('CustomMenuButton', { player.controlBar.addChild('CustomMenuButton', {
title: t('player.quality'), title: t('player.quality'),
items: formatedQualities items: [{ name: autoLabel }, ...formatedQualities]
}) })
if (!startupQualityApplied) {
startupQualityApplied = true
const startingQuality = String(getSetting('defaultQuality'))
const targetHeight = Number(startingQuality.replace(/[^0-9]/g, ''))
if (!Number.isFinite(targetHeight) || targetHeight <= 0) {
setAutoQuality()
return
}
// Prefer exact/default match; otherwise keep auto mode so ABR can adapt.
const id = formatedQualities.find((i) => i.height === targetHeight)?.id
setQuality(id)
}
} }
qualityLevels.on('addqualitylevel', () => { qualityLevels.on('addqualitylevel', () => {
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => { formatedQualities = qualityLevels.levels_.map((quality: QualityLevel, index: number) => {
let qualityFramerate = '' let qualityFramerate = ''
if (quality.frameRate > 30) { if (quality.frameRate > 30) {
@@ -80,8 +128,9 @@ export const createQualitySelector = (player: any) => {
return { return {
name: quality.height?.toString() + 'p' + qualityFramerate, name: quality.height?.toString() + 'p' + qualityFramerate,
index: quality.id[0], index,
id: quality.id id: quality.id,
height: quality.height || 0
} }
}) })
+24 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { inject } from 'vue' import { inject } from 'vue'
import { getEndpoint, abbreviate } from '@/mixins' import { getEndpoint, postEndpoint, abbreviate } from '@/mixins'
import type { StreamerData, CategoryMinifiedStream } from '@/types' import type { StreamerData, CategoryMinifiedStream } from '@/types'
export default { export default {
@@ -41,6 +41,29 @@ export default {
} }
} else { } else {
data = await getEndpoint('api/users/' + props.name) data = await getEndpoint('api/users/' + props.name)
if (!data.stream && props.name) {
// Fallback for backend variants where api/users marks everyone offline.
const liveUsers: string[] = await postEndpoint('api/users/isLive/bulk', {
streamers: [props.name]
}).catch(() => [])
const isActuallyLive = liveUsers.some(
(name) => name.toLowerCase() === props.name!.toLowerCase()
)
if (isActuallyLive) {
data.stream = {
title: 'Live now',
tags: [],
startedAt: 0,
topic: '',
viewers: 0,
preview: data.pfp
}
data.isLive = true
}
}
} }
const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN
+104 -3
View File
@@ -25,15 +25,77 @@ export default {
emits: ['PlayerTimeUpdate'], emits: ['PlayerTimeUpdate'],
setup() { setup() {
let player: any let player: any
let lastProgressAt = 0
let lastKnownTime = 0
let recoverTimer: ReturnType<typeof setInterval> | null = null
let lastRecoverAt = 0
return { return {
player player,
lastProgressAt,
lastKnownTime,
recoverTimer,
lastRecoverAt
} }
}, },
// initializing the video player // initializing the video player
// when the component is being mounted // when the component is being mounted
mounted() { mounted() {
const emit = this.$emit const emit = this.$emit
this.player = videojs('video-player', this.options, () => { const isLiveStreamSource = () =>
String(this.player?.currentSource()?.src || '').includes('/proxy/stream/')
const tryResumePlayback = () => {
if (!this.player || this.player.isDisposed()) {
return
}
const now = Date.now()
if (now - this.lastRecoverAt < 2500) {
return
}
this.lastRecoverAt = now
const source = this.player.currentSource()
if (!source?.src) {
return
}
const isLive = isLiveStreamSource()
const savedTime = Number(this.player.currentTime() || 0)
// Soft recovery first: if playback is paused/stuck, request play.
this.player.play().catch(() => {
// Hard recovery: reload source and restore position (for VOD/clip).
this.player.src(source)
this.player.one('loadedmetadata', () => {
if (!isLive && savedTime > 0) {
this.player.currentTime(savedTime)
} else if (isLive && this.player.liveTracker?.seekToLiveEdge) {
this.player.liveTracker.seekToLiveEdge()
}
this.player.play().catch(() => {
// If autoplay is blocked, keep player ready; user can click play.
})
})
})
}
const playerOptions = {
...this.options,
html5: {
...(this.options.html5 || {}),
vhs: {
// Lower initial bitrate pressure and let ABR adapt quickly.
enableLowInitialPlaylist: true,
limitRenditionByPlayerDimensions: true,
...(this.options.html5?.vhs || {})
}
}
}
this.player = videojs('video-player', playerOptions, () => {
createQualitySelector(this.player) createQualitySelector(this.player)
if (this.$route.query['t']) { if (this.$route.query['t']) {
@@ -43,11 +105,50 @@ export default {
} }
this.player.on('timeupdate', () => { this.player.on('timeupdate', () => {
emit('PlayerTimeUpdate', this.player.currentTime()) const currentTime = Number(this.player.currentTime() || 0)
if (currentTime !== this.lastKnownTime) {
this.lastKnownTime = currentTime
this.lastProgressAt = Date.now()
}
emit('PlayerTimeUpdate', currentTime)
}) })
this.player.on('waiting', tryResumePlayback)
this.player.on('stalled', tryResumePlayback)
this.player.on('error', tryResumePlayback)
// On live streams, ad transitions/discontinuities can fire ended.
this.player.on('ended', () => {
if (isLiveStreamSource()) {
tryResumePlayback()
}
})
// Watchdog: if live playback stops progressing for several seconds,
// trigger recovery even when no explicit media event fires.
this.lastProgressAt = Date.now()
this.recoverTimer = setInterval(() => {
if (!this.player || this.player.isDisposed()) {
return
}
if (!isLiveStreamSource()) {
return
}
const stalledFor = Date.now() - this.lastProgressAt
if (stalledFor > 8000) {
tryResumePlayback()
}
}, 3000)
}) })
}, },
unmounted() { unmounted() {
if (this.recoverTimer) {
clearInterval(this.recoverTimer)
this.recoverTimer = null
}
this.player.dispose() this.player.dispose()
} }
} }
+2
View File
@@ -9,3 +9,5 @@ onMounted(function () {
document.title = props.title document.title = props.title
}) })
</script> </script>
<template></template>
+3 -2
View File
@@ -108,7 +108,6 @@ export async function followersStreaming(streamers: string[], cursor: number): P
const payloadData = streamers.slice(cursor * 35, cursor * 35 + 35) const payloadData = streamers.slice(cursor * 35, cursor * 35 + 35)
console.log(payloadData)
const payload = { const payload = {
streamers: payloadData streamers: payloadData
} }
@@ -137,7 +136,9 @@ export async function getParsedFollowing(
let res: FollowingStreamer[] = [] let res: FollowingStreamer[] = []
const payloadData = streamers.slice(cursor, cursor + 35) const start = cursor * 35
const end = (cursor + 1) * 35
const payloadData = streamers.slice(start, end)
const payload = { const payload = {
streamers: payloadData streamers: payloadData
+3 -3
View File
@@ -80,7 +80,7 @@ export const setLanguage = (selectedLanguage: string, i18n: any) => {
const locale = locales[languages.indexOf(selectedLanguage)] const locale = locales[languages.indexOf(selectedLanguage)]
if (!locale) { if (!locale) {
throw Error("Error while setting locale, " + selectedLanguage + " does not exist.") throw Error('Error while setting locale, ' + selectedLanguage + ' does not exist.')
} }
localStorage.setItem('language', locale) localStorage.setItem('language', locale)
i18n.locale = locale i18n.locale = locale
@@ -101,8 +101,8 @@ export function getDefaultSettings(): Settings {
}, },
defaultQuality: { defaultQuality: {
name: 'defaultQuality', name: 'defaultQuality',
options: ['160p', '360p', '480p', '720p', '1080p'], options: ['Auto', '160p', '360p', '480p', '720p', '1080p'],
selected: '480p', selected: 'Auto',
type: 'option' type: 'option'
}, },
language: { language: {
+2 -2
View File
@@ -13,7 +13,7 @@ export default {
async setup() { async setup() {
const data = ref<CategoryData>() const data = ref<CategoryData>()
const status = ref<'ok' | 'error'>() const status = ref<'ok' | 'error'>()
const endOfScroll = ref<Boolean>() // Display message when user reaches bottom of page const endOfScroll = ref<boolean>() // Display message when user reaches bottom of page
return { return {
data, data,
@@ -59,7 +59,7 @@ export default {
const lastStreamCursor = this.data.streams[this.data.streams.length - 1]?.cursor ?? null const lastStreamCursor = this.data.streams[this.data.streams.length - 1]?.cursor ?? null
const newLastStreamCursor = resData.streams[resData.streams.length - 1]?.cursor ?? null const newLastStreamCursor = resData.streams[resData.streams.length - 1]?.cursor ?? null
if (lastStreamCursor === newLastStreamCursor) { if (lastStreamCursor === newLastStreamCursor) {
this.endOfScroll = true; this.endOfScroll = true
} else { } else {
for (const stream of resData.streams) { for (const stream of resData.streams) {
this.data.streams.push(stream) this.data.streams.push(stream)
+26 -5
View File
@@ -39,12 +39,14 @@ export default {
return return
} }
const cursor = this.data.length / 35 const cursor = Math.ceil(this.data.length / 35)
const maxCursor = follows.length / 35 const maxCursor = Math.floor(follows.length / 35)
if (cursor > maxCursor) return if (cursor > maxCursor) return
const chunk = await getParsedFollowing(follows, cursor) const chunk = await getParsedFollowing(follows, cursor)
this.data = [...this.data, ...chunk] this.data = [...this.data, ...chunk]
console.log(this.data[this.data.length - 1])
} }
}, },
async mounted() { async mounted() {
@@ -81,13 +83,32 @@ export default {
<ul class="m-2 flex flex-wrap justify-center"> <ul class="m-2 flex flex-wrap justify-center">
<li v-for="streamer in data" :key="streamer.login" class=""> <li v-for="streamer in data" :key="streamer.login" class="">
<div class="inline-flex bg-overlay0 p-2.5 m-1 rounded-md w-[22rem]"> <div
<img :src="streamer.pfp" class="w-16 rounded-full" /> v-if="streamer.username != String()"
class="inline-flex bg-overlay0 p-2.5 m-1 rounded-md w-[22rem]"
>
<img :src="streamer.pfp" class="w-16 h-16 rounded-full" />
<div class="justify-between flex flex-col ml-2 truncate text-ellipsis w-[8rem]"> <div class="justify-between flex flex-col ml-2 truncate text-ellipsis w-[8rem]">
<h1 class="text-2xl font-bold"> <h1 class="text-2xl font-bold">
<router-link :to="streamer.login">{{ streamer.username }}</router-link> <router-link :to="streamer.login">{{ streamer.username }}</router-link>
</h1> </h1>
<span>{{ abbreviate(streamer.followers) }} Followers</span> <span>{{ abbreviate(streamer.followers) }} {{ $t('main.followers') }}</span>
</div>
<div class="m-auto">
<follow-button :username="streamer.login"></follow-button>
</div>
</div>
<div
v-else="streamer.username != String()"
class="inline-flex bg-overlay0 p-2.5 m-1 rounded-md w-[22rem]"
>
<img class="w-16 h-16 rounded-full border-solid border-4 border-red" />
<div class="justify-between flex flex-col ml-2 truncate text-ellipsis w-[8rem]">
<h1 class="text-2xl font-bold">
<router-link :to="streamer.login">{{ streamer.login }}</router-link>
</h1>
<span class="text-red">{{ $t('main.suspended') }}</span>
</div> </div>
<div class="m-auto"> <div class="m-auto">
<follow-button :username="streamer.login"></follow-button> <follow-button :username="streamer.login"></follow-button>
+12 -16
View File
@@ -39,7 +39,7 @@ export default {
const tagElements = category.getElementsByTagName('span') const tagElements = category.getElementsByTagName('span')
const categoryTags = Array.from(tagElements, (el) => el.innerText.toLowerCase()) const categoryTags = Array.from(tagElements, (el) => el.innerText.toLowerCase())
let tagInputIsValid = wantedTags.length > 0 const tagInputIsValid = wantedTags.length > 0
if (tagInputIsValid && !categoryTags.some((r) => wantedTags.includes(r))) { if (tagInputIsValid && !categoryTags.some((r) => wantedTags.includes(r))) {
category.style.display = 'none' category.style.display = 'none'
} else { } else {
@@ -61,18 +61,6 @@ export default {
this.data.push(category) this.data.push(category)
} }
} }
},
handleFollowingScroll(event: Event) {
const el = event.target as HTMLUListElement
const fullyScrolled = el.scrollLeft === el.scrollWidth - el.clientWidth
if (!fullyScrolled) return
if (!this.following) return
const offset = Math.floor(this.following.length / 35)
console.log(offset)
followersStreaming(this.following, offset).then((newFollowers) => {
this.followingStreaming = [...this.following, ...newFollowers]
})
} }
}, },
async mounted() { async mounted() {
@@ -87,8 +75,14 @@ export default {
this.data = data this.data = data
}) })
this.followingStreaming = await followersStreaming(getFollows(), 0)
this.following = await getFollows() this.following = await getFollows()
this.followingStreaming = []
// There is a max of 35 streamers per request.
for (let i = 0; i <= Math.floor(this.following.length / 35); i++) {
const liveStreamers = await followersStreaming(this.following, i)
this.followingStreaming = [...this.followingStreaming, ...liveStreamers]
}
}, },
components: { components: {
StreamPreviewVue, StreamPreviewVue,
@@ -101,17 +95,19 @@ export default {
</script> </script>
<template> <template>
<vue-title title="Discover"></vue-title>
<loading-screen v-show="!data && status != 'error'"></loading-screen> <loading-screen v-show="!data && status != 'error'"></loading-screen>
<div v-show="status === 'error'"> <div v-show="status === 'error'">
<error-message></error-message> <error-message></error-message>
</div> </div>
<div v-show="data" class="max-w-5xl grow w-[100vw] mx-auto"> <div v-show="data" class="max-w-5xl grow w-[100vw] mx-auto">
<vue-title title="Discover"></vue-title>
<div v-if="following && following.length > 0" class="p-2 text-contrast mb-4"> <div v-if="following && following.length > 0" class="p-2 text-contrast mb-4">
<h1 class="font-bold text-5xl">{{ $t('home.following') }}</h1> <h1 class="font-bold text-5xl">{{ $t('home.following') }}</h1>
<p class="text-xl mb-2">{{ $t('home.streamersYouFollow') }}</p> <p class="text-xl mb-2">{{ $t('home.streamersYouFollow') }}</p>
<ul class="overflow-x-auto whitespace-nowrap flex grid grid-rows-2 auto-cols-min gap-2 grid-flow-col" @scroll="handleFollowingScroll"> <ul
class="overflow-x-auto whitespace-nowrap flex grid grid-rows-2 auto-cols-min gap-2 grid-flow-col"
>
<li v-for="streamer in followingStreaming" :key="streamer" class="mr-2"> <li v-for="streamer in followingStreaming" :key="streamer" class="mr-2">
<stream-preview-vue :name="streamer"></stream-preview-vue> <stream-preview-vue :name="streamer"></stream-preview-vue>
</li> </li>
+86 -8
View File
@@ -20,16 +20,94 @@ export default {
status status
} }
}, },
async mounted() { watch: {
await getEndpoint('api/search/?query=' + this.$route.query.query) '$route.query.query': {
.catch(() => { immediate: true,
this.status = 'error' handler() {
}) this.runSearch()
.then((data) => { }
this.data = data as SearchResult }
})
}, },
methods: { methods: {
hasAnyResults(data: SearchResult) {
return (
data.channels.length > 0 ||
data.categories.length > 0 ||
data.relatedChannels.length > 0 ||
data.channelsWithTag.length > 0
)
},
async buildFallbackSearch(query: string): Promise<SearchResult> {
const fallback: SearchResult = {
channels: [],
categories: [],
relatedChannels: [],
channelsWithTag: []
}
const encodedQuery = encodeURIComponent(query)
await getEndpoint('api/users/' + encodedQuery)
.then((channel) => {
if (channel) {
fallback.channels.push(channel)
}
})
.catch(() => {
// Ignore fallback channel lookup errors.
})
await getEndpoint('api/discover')
.then((categories) => {
const loweredQuery = query.toLowerCase()
fallback.categories = categories
.filter((category: { name: string; displayName?: string }) => {
const name = String(category.name || '').toLowerCase()
const displayName = String(category.displayName || '').toLowerCase()
return name.includes(loweredQuery) || displayName.includes(loweredQuery)
})
.slice(0, 24)
})
.catch(() => {
// Ignore fallback category lookup errors.
})
return fallback
},
async runSearch() {
const query = String(this.$route.query.query || '').trim()
if (!query) {
this.data = {
channels: [],
categories: [],
relatedChannels: [],
channelsWithTag: []
}
this.status = undefined
return
}
this.data = undefined
this.status = undefined
const encodedQuery = encodeURIComponent(query)
await getEndpoint('api/search/?query=' + encodedQuery)
.then(async (data) => {
const parsed = data as SearchResult
if (this.hasAnyResults(parsed)) {
this.data = parsed
return
}
this.data = await this.buildFallbackSearch(query)
})
.catch(async () => {
// If primary search errors, still attempt a best-effort fallback.
this.data = await this.buildFallbackSearch(query)
if (!this.hasAnyResults(this.data)) {
this.status = 'error'
}
})
},
getStream(channel: StreamerData) { getStream(channel: StreamerData) {
return { return {
...channel.stream, ...channel.stream,
+31 -1
View File
@@ -15,7 +15,7 @@ import VueTitle from '@/components/VueTitle.vue'
import ActionButtons from '@/components/ActionButtons.vue' import ActionButtons from '@/components/ActionButtons.vue'
import type { StreamerData } from '@/types' import type { StreamerData } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint, postEndpoint } from '@/mixins'
import { getSetting } from '@/settingsManager' import { getSetting } from '@/settingsManager'
export default { export default {
@@ -65,6 +65,36 @@ export default {
.catch(() => { .catch(() => {
this.status = 'error' this.status = 'error'
}) })
// Some backend builds return stale isLive/stream in api/users while
// api/users/isLive/bulk is still accurate. Use it as a fallback.
if (this.data && (this.data.isLive === false || !this.data.stream)) {
await postEndpoint('api/users/isLive/bulk', {
streamers: [username]
})
.then((liveUsers: string[]) => {
const isActuallyLive = liveUsers.some(
(name) => name.toLowerCase() === String(username).toLowerCase()
)
if (isActuallyLive) {
this.data!.isLive = true
if (!this.data!.stream) {
// Provide minimal stream metadata so live UI paths do not crash.
this.data!.stream = {
title: 'Live now',
tags: [],
topic: '',
startedAt: 0,
viewers: 0,
preview: this.data!.pfp
}
}
}
})
.catch(() => {
// Keep original data when fallback lookup fails.
})
}
}, },
components: { components: {
VideoPlayer, VideoPlayer,