Deploy local stack fixes and search fallback
This commit is contained in:
+2
-2
@@ -3,7 +3,7 @@
|
||||
# 2) nginx stage to serve frontend assets
|
||||
|
||||
# Name the node stage "builder"
|
||||
FROM docker.io/node:16 AS builder
|
||||
FROM docker.io/node:22 AS builder
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -23,7 +23,7 @@ COPY ./ .
|
||||
|
||||
RUN ls
|
||||
# install node modules and build assets
|
||||
RUN npm i && npm run build
|
||||
RUN npm i && npm run build-only
|
||||
|
||||
# nginx state for serving content
|
||||
FROM docker.io/nginx:alpine
|
||||
|
||||
+103
-10
@@ -1,19 +1,49 @@
|
||||
version: "3.7"
|
||||
name: safetwitch
|
||||
|
||||
services:
|
||||
safetwitch-frontend:
|
||||
container_name: safetwitch-frontend
|
||||
hostname: safetwitch-frontend
|
||||
restart: always
|
||||
image: codeberg.org/safetwitch/safetwitch:latest
|
||||
image: safetwitch-local:latest
|
||||
build:
|
||||
context: "../"
|
||||
dockerfile: "./docker/Dockerfile"
|
||||
ports:
|
||||
- "127.0.0.1:8280:8280"
|
||||
- "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280"
|
||||
environment:
|
||||
- SAFETWITCH_BACKEND_DOMAIN=changeme
|
||||
- SAFETWITCH_INSTANCE_DOMAIN=changeme
|
||||
- SAFETWITCH_HTTPS=true
|
||||
- SAFETWITCH_DEFAULT_LOCALE=en
|
||||
- SAFETWITCH_FALLBACK_LOCALE=en
|
||||
- SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services}
|
||||
- SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services}
|
||||
- SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true}
|
||||
- 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.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:
|
||||
container_name: safetwitch-backend
|
||||
@@ -26,7 +56,70 @@ services:
|
||||
restart: always
|
||||
image: codeberg.org/safetwitch/safetwitch-backend:latest
|
||||
ports:
|
||||
- "127.0.0.1:7100:7000"
|
||||
- "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000"
|
||||
environment:
|
||||
- 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
@@ -4,9 +4,26 @@ http {
|
||||
|
||||
server {
|
||||
listen 8280;
|
||||
access_log /dev/null;
|
||||
access_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 / {
|
||||
root /app;
|
||||
|
||||
@@ -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')
|
||||
@@ -10,22 +10,55 @@ export const createQualitySelector = (player: any) => {
|
||||
const qualityLevels: QualityLevelList = player.qualityLevels()
|
||||
const MenuButton = videojs.getComponent('MenuButton')
|
||||
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 translatedAuto = t('player.auto')
|
||||
const autoLabel = translatedAuto === 'player.auto' ? 'Auto' : translatedAuto
|
||||
|
||||
const setQuality = (id: string) => {
|
||||
const found = formatedQualities.find((i) => i.id === id)
|
||||
const setAutoQuality = () => {
|
||||
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
|
||||
} else {
|
||||
quality.enabled = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep adaptive bitrate enabled up to the selected quality.
|
||||
quality.enabled = quality.height <= found.height
|
||||
}
|
||||
|
||||
qualityLevels.selectedIndex_ = found?.index
|
||||
qualityLevels.trigger({ type: 'change', selectedIndex: found?.index })
|
||||
|
||||
player.play().catch(() => {
|
||||
// Ignore autoplay restrictions; user interaction can resume.
|
||||
})
|
||||
}
|
||||
|
||||
class CustomMenuButton extends MenuButton {
|
||||
@@ -39,6 +72,11 @@ export const createQualitySelector = (player: any) => {
|
||||
|
||||
qualitySelectorButton.handleClick = (data) => {
|
||||
const qualityClicked = data.currentTarget.innerText
|
||||
if (qualityClicked === autoLabel) {
|
||||
setAutoQuality()
|
||||
return
|
||||
}
|
||||
|
||||
const id = formatedQualities.find((i) => i.name === qualityClicked)?.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
|
||||
})
|
||||
}
|
||||
@@ -66,12 +99,27 @@ export const createQualitySelector = (player: any) => {
|
||||
player.controlBar.removeChild('CustomMenuButton')
|
||||
player.controlBar.addChild('CustomMenuButton', {
|
||||
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', () => {
|
||||
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => {
|
||||
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel, index: number) => {
|
||||
let qualityFramerate = ''
|
||||
|
||||
if (quality.frameRate > 30) {
|
||||
@@ -80,8 +128,9 @@ export const createQualitySelector = (player: any) => {
|
||||
|
||||
return {
|
||||
name: quality.height?.toString() + 'p' + qualityFramerate,
|
||||
index: quality.id[0],
|
||||
id: quality.id
|
||||
index,
|
||||
id: quality.id,
|
||||
height: quality.height || 0
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { getEndpoint, abbreviate } from '@/mixins'
|
||||
import { getEndpoint, postEndpoint, abbreviate } from '@/mixins'
|
||||
import type { StreamerData, CategoryMinifiedStream } from '@/types'
|
||||
|
||||
export default {
|
||||
@@ -41,6 +41,29 @@ export default {
|
||||
}
|
||||
} else {
|
||||
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
|
||||
|
||||
@@ -25,15 +25,77 @@ export default {
|
||||
emits: ['PlayerTimeUpdate'],
|
||||
setup() {
|
||||
let player: any
|
||||
let lastProgressAt = 0
|
||||
let lastKnownTime = 0
|
||||
let recoverTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastRecoverAt = 0
|
||||
|
||||
return {
|
||||
player
|
||||
player,
|
||||
lastProgressAt,
|
||||
lastKnownTime,
|
||||
recoverTimer,
|
||||
lastRecoverAt
|
||||
}
|
||||
},
|
||||
// initializing the video player
|
||||
// when the component is being mounted
|
||||
mounted() {
|
||||
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)
|
||||
|
||||
if (this.$route.query['t']) {
|
||||
@@ -43,11 +105,50 @@ export default {
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.recoverTimer) {
|
||||
clearInterval(this.recoverTimer)
|
||||
this.recoverTimer = null
|
||||
}
|
||||
this.player.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ export function getDefaultSettings(): Settings {
|
||||
},
|
||||
defaultQuality: {
|
||||
name: 'defaultQuality',
|
||||
options: ['160p', '360p', '480p', '720p', '1080p'],
|
||||
selected: '480p',
|
||||
options: ['Auto', '160p', '360p', '480p', '720p', '1080p'],
|
||||
selected: 'Auto',
|
||||
type: 'option'
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -20,16 +20,94 @@ export default {
|
||||
status
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await getEndpoint('api/search/?query=' + this.$route.query.query)
|
||||
.catch(() => {
|
||||
this.status = 'error'
|
||||
})
|
||||
.then((data) => {
|
||||
this.data = data as SearchResult
|
||||
})
|
||||
watch: {
|
||||
'$route.query.query': {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.runSearch()
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
return {
|
||||
...channel.stream,
|
||||
|
||||
+31
-1
@@ -15,7 +15,7 @@ import VueTitle from '@/components/VueTitle.vue'
|
||||
import ActionButtons from '@/components/ActionButtons.vue'
|
||||
|
||||
import type { StreamerData } from '@/types'
|
||||
import { truncate, abbreviate, getEndpoint } from '@/mixins'
|
||||
import { truncate, abbreviate, getEndpoint, postEndpoint } from '@/mixins'
|
||||
import { getSetting } from '@/settingsManager'
|
||||
|
||||
export default {
|
||||
@@ -65,6 +65,36 @@ export default {
|
||||
.catch(() => {
|
||||
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: {
|
||||
VideoPlayer,
|
||||
|
||||
Reference in New Issue
Block a user