This commit is contained in:
dragongoose 2023-11-04 19:52:55 -04:00
parent 961d3bfa67
commit 4dd94d4af1
No known key found for this signature in database
GPG Key ID: 01397EEC371CDAA5
20 changed files with 509 additions and 443 deletions

View File

@ -15,9 +15,9 @@ const dev = import.meta.env.DEV
<navbar-item></navbar-item> <navbar-item></navbar-item>
<Suspense> <Suspense>
<RouterView :key="$route.fullPath"/> <RouterView :key="$route.fullPath" />
</Suspense> </Suspense>
<footer-item></footer-item> <footer-item></footer-item>
</div> </div>
</template> </template>

View File

@ -72,7 +72,7 @@ export const createQualitySelector = (player: any) => {
qualityLevels.on('addqualitylevel', () => { qualityLevels.on('addqualitylevel', () => {
formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => { formatedQualities = qualityLevels.levels_.map((quality: QualityLevel) => {
let qualityFramerate = "" let qualityFramerate = ''
if (quality.frameRate > 30) { if (quality.frameRate > 30) {
qualityFramerate = quality.frameRate qualityFramerate = quality.frameRate
@ -86,9 +86,9 @@ export const createQualitySelector = (player: any) => {
}) })
// remove audio only // remove audio only
const audioOnlyQuality = formatedQualities[formatedQualities.length-1] const audioOnlyQuality = formatedQualities[formatedQualities.length - 1]
if (audioOnlyQuality.name === "undefinedp") { if (audioOnlyQuality.name === 'undefinedp') {
formatedQualities.splice(formatedQualities.length-1, 1) formatedQualities.splice(formatedQualities.length - 1, 1)
} }
formattedLevels.push(formatedQualities) formattedLevels.push(formatedQualities)

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<audio ref="videoPlayer" class="w-full" controls></audio> <audio ref="videoPlayer" class="w-full" controls></audio>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
// Importing video-js // Importing video-js
@ -9,54 +9,53 @@ import Hls from 'hls.js'
import { getSetting } from '@/settingsManager' import { getSetting } from '@/settingsManager'
export default { export default {
name: 'VideoJsPlayer', name: 'VideoJsPlayer',
props: { props: {
masterManifestUrl: { masterManifestUrl: {
type: String, type: String,
required: true, required: true
}
},
emits: ['PlayerTimeUpdate'],
async setup(props) {
let player: any
const getAudioOnlyManifestFromUrl = async (masterManifestUrl: string) => {
const manifestRes = await fetch(masterManifestUrl)
if (!manifestRes.ok) return;
const manifest = await manifestRes.text()
// The last line of the manifest is the
// audio only manifest. This is a bit hacky
// but it'll work. If issues arise we can
// always implement an actual m3u8 parser
const tmp = manifest.split("\n")
const audioOnlyManifest = tmp[tmp.length - 1]
return audioOnlyManifest
}
const audioOnlyManifest = await getAudioOnlyManifestFromUrl(props.masterManifestUrl)
return {
player,
audioOnlyManifest
}
},
// initializing the video player
// when the component is being mounted
mounted() {
const video = this.$refs.videoPlayer as HTMLVideoElement;
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(this.audioOnlyManifest || "");
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if(getSetting("autoplay")) {
video.play();
}
});
}
} }
},
emits: ['PlayerTimeUpdate'],
async setup(props) {
let player: any
const getAudioOnlyManifestFromUrl = async (masterManifestUrl: string) => {
const manifestRes = await fetch(masterManifestUrl)
if (!manifestRes.ok) return
const manifest = await manifestRes.text()
// The last line of the manifest is the
// audio only manifest. This is a bit hacky
// but it'll work. If issues arise we can
// always implement an actual m3u8 parser
const tmp = manifest.split('\n')
const audioOnlyManifest = tmp[tmp.length - 1]
return audioOnlyManifest
}
const audioOnlyManifest = await getAudioOnlyManifestFromUrl(props.masterManifestUrl)
return {
player,
audioOnlyManifest
}
},
// initializing the video player
// when the component is being mounted
mounted() {
const video = this.$refs.videoPlayer as HTMLVideoElement
if (Hls.isSupported()) {
const hls = new Hls()
hls.loadSource(this.audioOnlyManifest || '')
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (getSetting('autoplay')) {
video.play()
}
})
}
}
} }
</script> </script>

View File

@ -39,10 +39,9 @@ export default {
<ul class="h-8 overflow-hidden"> <ul class="h-8 overflow-hidden">
<li v-for="tag in category.tags" :key="tag" class="inline-flex"> <li v-for="tag in category.tags" :key="tag" class="inline-flex">
<span <span class="p-2.5 py-1.5 bg-surface0 rounded-md m-0.5 text-xs font-bold text-contrast">{{
class="p-2.5 py-1.5 bg-surface0 rounded-md m-0.5 text-xs font-bold text-contrast" tag
>{{ tag }}</span }}</span>
>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -18,7 +18,7 @@ export default {
methods: { methods: {
followStreamer() { followStreamer() {
const username = this.$props.username const username = this.$props.username
const follows = localStorage.getItem('following') || "[]" const follows = localStorage.getItem('following') || '[]'
let parsedFollows: string[] = JSON.parse(follows) let parsedFollows: string[] = JSON.parse(follows)

View File

@ -4,12 +4,12 @@ export default {
return { return {
version: `${import.meta.env.SAFETWITCH_TAG}-${import.meta.env.SAFETWITCH_COMMIT_HASH}` version: `${import.meta.env.SAFETWITCH_TAG}-${import.meta.env.SAFETWITCH_COMMIT_HASH}`
} }
}, }
} }
</script> </script>
<template> <template>
<div class="m-2 mt-5 flex justify-center"> <div class="m-2 mt-5 flex justify-center">
<p class="text-contrast font-bold">SafeTwitch {{ version}}</p> <p class="text-contrast font-bold">SafeTwitch {{ version }}</p>
</div> </div>
</template> </template>

View File

@ -17,7 +17,7 @@ export default {
}, },
mounted() { mounted() {
const savedLocale = localStorage.getItem('language') const savedLocale = localStorage.getItem('language')
if(savedLocale) { if (savedLocale) {
this.$i18n.locale = savedLocale this.$i18n.locale = savedLocale
} }
} }
@ -35,16 +35,10 @@ export default {
<search-bar class="mt-4 mr-4 hidden sm:inline-block sm:mt-0"></search-bar> <search-bar class="mt-4 mr-4 hidden sm:inline-block sm:mt-0"></search-bar>
<div class="text-contrast hidden space-x-4 sm:block"> <div class="text-contrast hidden space-x-4 sm:block">
<a <a href="https://codeberg.org/dragongoose/safetwitch" target="_blank">{{ $t('nav.code') }}</a>
href="https://codeberg.org/dragongoose/safetwitch"
target="_blank"
>{{ $t('nav.code') }}</a
>
<a :href="'https://twitch.tv' + $route.fullPath">Twitch</a> <a :href="'https://twitch.tv' + $route.fullPath">Twitch</a>
<router-link to="/privacy">{{ <router-link to="/privacy">{{ $t('nav.privacy') }}</router-link>
$t('nav.privacy') <router-link to="/settings">{{ $t('nav.settings') }}</router-link>
}}</router-link>
<router-link to="/settings">{{ $t("nav.settings") }}</router-link>
</div> </div>
<div class="block sm:hidden"> <div class="block sm:hidden">
@ -63,7 +57,7 @@ export default {
<a href="https://codeberg.org/dragongoose/safetwitch">{{ $t('nav.code') }}</a> <a href="https://codeberg.org/dragongoose/safetwitch">{{ $t('nav.code') }}</a>
<a :href="'https://twitch.tv' + $route.fullPath">Twitch</a> <a :href="'https://twitch.tv' + $route.fullPath">Twitch</a>
<router-link to="/privacy">{{ $t('nav.privacy') }}</router-link> <router-link to="/privacy">{{ $t('nav.privacy') }}</router-link>
<router-link to="/settings">{{ $t("nav.settings") }}</router-link> <router-link to="/settings">{{ $t('nav.settings') }}</router-link>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -24,9 +24,9 @@ export default {
login: streamData.streamer.name, login: streamData.streamer.name,
followers: 0, followers: 0,
isLive: true, isLive: true,
about: "", about: '',
pfp: streamData.streamer.pfp, pfp: streamData.streamer.pfp,
banner: "", banner: '',
isPartner: false, isPartner: false,
colorHex: streamData.streamer.colorHex, colorHex: streamData.streamer.colorHex,
id: 0, id: 0,
@ -34,13 +34,13 @@ export default {
title: streamData.title, title: streamData.title,
tags: streamData.tags, tags: streamData.tags,
startedAt: 0, startedAt: 0,
topic: "", topic: '',
viewers: streamData.viewers, viewers: streamData.viewers,
preview: streamData.preview preview: streamData.preview
} }
} }
} else { } else {
data = await getEndpoint("api/users/" + props.name) data = await getEndpoint('api/users/' + props.name)
} }
const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN const frontend_url = protocol + import.meta.env.SAFETWITCH_INSTANCE_DOMAIN
@ -68,7 +68,9 @@ export default {
<div class="inline-flex"> <div class="inline-flex">
<img :src="data.pfp" class="rounded-full mr-2" /> <img :src="data.pfp" class="rounded-full mr-2" />
<div class="w-full"> <div class="w-full">
<p class="font-bold w-[18rem] md:w-[22.9rem] truncate" :title="data.stream.title">{{ data.stream.title }}</p> <p class="font-bold w-[18rem] md:w-[22.9rem] truncate" :title="data.stream.title">
{{ data.stream.title }}
</p>
<div class="inline-flex w-full justify-between"> <div class="inline-flex w-full justify-between">
<p class="text-neutral">{{ data.username }}</p> <p class="text-neutral">{{ data.username }}</p>
<p class="self-end float-right"> <p class="self-end float-right">

View File

@ -37,7 +37,7 @@ export default {
createQualitySelector(this.player) createQualitySelector(this.player)
if (this.$route.query['t']) { if (this.$route.query['t']) {
const timeQuery = this.$route.query['t'].toString() || "" const timeQuery = this.$route.query['t'].toString() || ''
const time = getTimeFromQuery(timeQuery) const time = getTimeFromQuery(timeQuery)
this.player.currentTime(time) this.player.currentTime(time)
} }

View File

@ -1,108 +1,129 @@
<script lang="ts"> <script lang="ts">
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router'
import { ref } from "vue"; import { ref } from 'vue'
export default { export default {
setup() { setup() {
const route = useRoute() const route = useRoute()
const instanceUrl = location.protocol + '//' + location.host; const instanceUrl = location.protocol + '//' + location.host
let currentUrl = ref(instanceUrl) let currentUrl = ref(instanceUrl)
return { return {
// remove any query from the path // remove any query from the path
path: route.fullPath.split("?")[0], path: route.fullPath.split('?')[0],
usingTwitchUrl: ref(false), usingTwitchUrl: ref(false),
usingTime: ref(false), usingTime: ref(false),
query: ref(""), query: ref(''),
instanceUrl, instanceUrl,
currentUrl currentUrl
}
},
emits: ['close'],
props: {
time: {
type: Number,
default() {
return 0
}
},
useTime: {
type: Boolean,
default() {
return false
}
}
},
methods: {
formatSecondsToQuery(sec: number) {
const date = new Date(sec * 1000)
const hour = date.getHours()
const minutes = date.getMinutes()
const seconds = date.getSeconds()
return `${hour}h${minutes}m${seconds}s`
},
toggleTwitchUrl() {
if (this.usingTwitchUrl) {
this.currentUrl = "https://twitch.tv"
} else {
this.currentUrl = this.instanceUrl
}
},
async copyUrl() {
try {
await navigator.clipboard.writeText(this.currentUrl + this.path + this.query);
console.log('Content copied to clipboard');
} catch (err) {
console.error('Failed to copy: ', err);
}
},
toggleTime() {
if (this.usingTime) {
const timestamp = this.formatSecondsToQuery(this.$props.time)
this.query = "?t=" + timestamp
} else {
this.query = ""
}
},
gotoUrl() {
location.href = this.currentUrl + this.path + this.query
}
} }
},
emits: ['close'],
props: {
time: {
type: Number,
default() {
return 0
}
},
useTime: {
type: Boolean,
default() {
return false
}
}
},
methods: {
formatSecondsToQuery(sec: number) {
const date = new Date(sec * 1000)
const hour = date.getHours()
const minutes = date.getMinutes()
const seconds = date.getSeconds()
return `${hour}h${minutes}m${seconds}s`
},
toggleTwitchUrl() {
if (this.usingTwitchUrl) {
this.currentUrl = 'https://twitch.tv'
} else {
this.currentUrl = this.instanceUrl
}
},
async copyUrl() {
try {
await navigator.clipboard.writeText(this.currentUrl + this.path + this.query)
console.log('Content copied to clipboard')
} catch (err) {
console.error('Failed to copy: ', err)
}
},
toggleTime() {
if (this.usingTime) {
const timestamp = this.formatSecondsToQuery(this.$props.time)
this.query = '?t=' + timestamp
} else {
this.query = ''
}
},
gotoUrl() {
location.href = this.currentUrl + this.path + this.query
}
}
} }
</script> </script>
<template> <template>
<div class="fixed top-0 bottom-0 left-0 right-0 flex w-full z-50 h-[100vh] bg-opacity-50 bg-black"> <div
<div class="bg-crust my-auto h-min mx-auto w-[35rem] max-w-[95vw] p-5 rounded-md relative z-50 text-contrast"> class="fixed top-0 bottom-0 left-0 right-0 flex w-full z-50 h-[100vh] bg-opacity-50 bg-black"
<div class="flex justify-between"> >
<h1 class="text-3xl font-bold">{{ $t('share.share') }}</h1> <div
<button @click="$emit('close')"> class="bg-crust my-auto h-min mx-auto w-[35rem] max-w-[95vw] p-5 rounded-md relative z-50 text-contrast"
<v-icon name="io-close-sharp" scale="1.8"></v-icon> >
</button> <div class="flex justify-between">
</div> <h1 class="text-3xl font-bold">{{ $t('share.share') }}</h1>
<hr class="my-2" /> <button @click="$emit('close')">
<div class="flex bg-surface0 p-3 rounded-md h-12 overflow-x-scroll whitespace-nowrap"> <v-icon name="io-close-sharp" scale="1.8"></v-icon>
<p class="" ref="urlPreview"> </button>
{{ currentUrl + path + query }} </div>
</p> <hr class="my-2" />
</div> <div class="flex bg-surface0 p-3 rounded-md h-12 overflow-x-scroll whitespace-nowrap">
<ul class="mt-1"> <p class="" ref="urlPreview">
<li> {{ currentUrl + path + query }}
<label class="flex items-center"> </p>
<input :disabled="!useTime" class="align-middle w-4 h-4 mr-1 disabled:opacity-50" type="checkbox" @change="toggleTime()" v-model="usingTime" /> {{ $t('share.addTimestamp') }} </div>
</label> <ul class="mt-1">
<li>
<label class="flex items-center">
<input
:disabled="!useTime"
class="align-middle w-4 h-4 mr-1 disabled:opacity-50"
type="checkbox"
@change="toggleTime()"
v-model="usingTime"
/>
{{ $t('share.addTimestamp') }}
</label>
<label class="flex items-center"> <label class="flex items-center">
<input class="align-middle w-4 h-4 mr-1" @change="toggleTwitchUrl()" v-model="usingTwitchUrl" type="checkbox" /> {{ $t('share.twitchUrl') }} <input
</label> class="align-middle w-4 h-4 mr-1"
</li> @change="toggleTwitchUrl()"
</ul> v-model="usingTwitchUrl"
type="checkbox"
/>
{{ $t('share.twitchUrl') }}
</label>
</li>
</ul>
<div class="space-x-2 mt-1"> <div class="space-x-2 mt-1">
<button class="p-2 py-1.5 bg-surface0 rounded-md" @click="copyUrl()">{{ $t('share.copyLink') }}</button> <button class="p-2 py-1.5 bg-surface0 rounded-md" @click="copyUrl()">
<button class="p-2 py-1.5 bg-surface0 rounded-md" @click="gotoUrl()" >{{ $t('share.goto') }}</button> {{ $t('share.copyLink') }}
</div> </button>
</div> <button class="p-2 py-1.5 bg-surface0 rounded-md" @click="gotoUrl()">
{{ $t('share.goto') }}
</button>
</div>
</div> </div>
</div>
</template> </template>

View File

@ -1,51 +1,54 @@
<script lang="ts"> <script lang="ts">
import { getSetting } from '@/settingsManager' import { getSetting } from '@/settingsManager'
import type { Social } from '@/types'; import type { Social } from '@/types'
import type { PropType } from 'vue'; import type { PropType } from 'vue'
export default { export default {
props: { props: {
socials: { socials: {
type: Object as PropType<Social[]> type: Object as PropType<Social[]>
},
about: {
type: String
}
}, },
methods: { about: {
getSetting, type: String
getIconName(iconType: string) {
console.log(iconType)
const icons = ["Twitter", "instagram", "discord", "youtube", "tiktok", "reddit"]
if (icons.includes(iconType)) {
return "bi-" + iconType
} else {
return "bi-link-45deg"
}
}
} }
},
methods: {
getSetting,
getIconName(iconType: string) {
console.log(iconType)
const icons = ['Twitter', 'instagram', 'discord', 'youtube', 'tiktok', 'reddit']
if (icons.includes(iconType)) {
return 'bi-' + iconType
} else {
return 'bi-link-45deg'
}
}
}
} }
</script> </script>
<template> <template>
<div v-if="getSetting('streamerAboutSectionVisible')" class="bg-primary mt-1 p-5 pt-3 rounded-lg w-full space-y-3"> <div
<div class="inline-flex w-full"> v-if="getSetting('streamerAboutSectionVisible')"
<span class="pr-3 font-bold text-3xl">{{ $t('streamer.about') }}</span> class="bg-primary mt-1 p-5 pt-3 rounded-lg w-full space-y-3"
</div> >
<div class="inline-flex w-full">
<p class="mb-5">{{ about || $t('streamer.noAbout') }}</p> <span class="pr-3 font-bold text-3xl">{{ $t('streamer.about') }}</span>
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
<ul v-if="socials" class="flex font-semibold text-md justify-start flex-wrap flex-row">
<li v-for="link in socials" :key="link.url">
<a :href="link.url" class="text-contrast hover:text-neutral mr-4 flex">
<v-icon :name="getIconName(link.type)" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span>
</a>
</li>
</ul>
<p v-else> {{$t('streamer.noSocials')}} </p>
</div> </div>
<p class="mb-5">{{ about || $t('streamer.noAbout') }}</p>
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
<ul v-if="socials" class="flex font-semibold text-md justify-start flex-wrap flex-row">
<li v-for="link in socials" :key="link.url">
<a :href="link.url" class="text-contrast hover:text-neutral mr-4 flex">
<v-icon :name="getIconName(link.type)" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span>
</a>
</li>
</ul>
<p v-else>{{ $t('streamer.noSocials') }}</p>
</div>
</template> </template>

View File

@ -14,7 +14,6 @@ import fr from '@/locales/fr.json'
import uk from '@/locales/uk.json' import uk from '@/locales/uk.json'
import de from '@/locales/de.json' import de from '@/locales/de.json'
export default createI18n({ export default createI18n({
legacy: false, legacy: false,
locale: import.meta.env.VUE_APP_I18N_LOCALE || 'en-US', locale: import.meta.env.VUE_APP_I18N_LOCALE || 'en-US',
@ -34,6 +33,6 @@ export default createI18n({
'it-IT': it, 'it-IT': it,
'fr-FR': fr, 'fr-FR': fr,
'uk-UA': uk, 'uk-UA': uk,
'de-DE': de, 'de-DE': de
} }
}) })

View File

@ -3,7 +3,6 @@ const https = import.meta.env.SAFETWITCH_HTTPS.slice() === 'true'
const protocol = https ? 'https://' : 'http://' const protocol = https ? 'https://' : 'http://'
const rootBackendUrl = `${protocol}${import.meta.env.SAFETWITCH_BACKEND_DOMAIN}/` const rootBackendUrl = `${protocol}${import.meta.env.SAFETWITCH_BACKEND_DOMAIN}/`
export function truncate(value: string, length: number) { export function truncate(value: string, length: number) {
if (value.length > length) { if (value.length > length) {
return value.substring(0, length) + '...' return value.substring(0, length) + '...'
@ -53,7 +52,7 @@ export async function getEndpoint(endpoint: string) {
*/ */
export function getTimeFromQuery(query: string) { export function getTimeFromQuery(query: string) {
// H, M, S // H, M, S
const x = query.split(/[^0-9.]/g); const x = query.split(/[^0-9.]/g)
const times = x.map(Number) const times = x.map(Number)
let time = 0 let time = 0
@ -62,4 +61,3 @@ export function getTimeFromQuery(query: string) {
time += times[2] time += times[2]
return time return time
} }

View File

@ -1,30 +1,60 @@
const locales = ['en-US', 'es-ES', 'nl-NL', 'pt-PT', 'fa-IR', 'he-IL', 'ru-RU', 'ko-KR', 'cs-CZ', 'pl-PL', 'it-IT', 'fr-FR', 'uk-UA', 'de-DE'] const locales = [
const languages = ['English', 'Español', 'Nederlands', 'Português', 'فارسی', 'עִבְרִית', 'Русский', '한국어', 'Česky', 'Polski', 'Italia', 'Français', 'Українська', 'Deutsch'] 'en-US',
'es-ES',
'nl-NL',
'pt-PT',
'fa-IR',
'he-IL',
'ru-RU',
'ko-KR',
'cs-CZ',
'pl-PL',
'it-IT',
'fr-FR',
'uk-UA',
'de-DE'
]
const languages = [
'English',
'Español',
'Nederlands',
'Português',
'فارسی',
'עִבְרִית',
'Русский',
'한국어',
'Česky',
'Polski',
'Italia',
'Français',
'Українська',
'Deutsch'
]
export interface SettingsCheckbox { export interface SettingsCheckbox {
name: string name: string
selected: boolean, selected: boolean
type: 'checkbox' type: 'checkbox'
} }
export interface SettingsOptions { export interface SettingsOptions {
name: string name: string
options: string[] options: string[]
selected: string selected: string
type: 'option' type: 'option'
} }
export interface Settings { export interface Settings {
version: string version: string
settings: { settings: {
audioOnly: SettingsCheckbox audioOnly: SettingsCheckbox
defaultQuality: SettingsOptions defaultQuality: SettingsOptions
language: SettingsOptions language: SettingsOptions
chatVisible: SettingsCheckbox chatVisible: SettingsCheckbox
streamTagsVisible: SettingsCheckbox streamTagsVisible: SettingsCheckbox
streamerAboutSectionVisible: SettingsCheckbox streamerAboutSectionVisible: SettingsCheckbox
autoplay: SettingsCheckbox autoplay: SettingsCheckbox
} }
} }
/** /**
@ -33,10 +63,10 @@ export interface Settings {
* @param i18n the i18n class in use * @param i18n the i18n class in use
*/ */
export const setLanguage = (selectedLanguage: string, i18n: any) => { export const setLanguage = (selectedLanguage: string, i18n: any) => {
// Locales and languages in arrays to match them // Locales and languages in arrays to match them
const locale = locales[languages.indexOf(selectedLanguage)] const locale = locales[languages.indexOf(selectedLanguage)]
localStorage.setItem("language", locale) localStorage.setItem('language', locale)
i18n.locale = locale i18n.locale = locale
} }
/** /**
@ -44,48 +74,48 @@ export const setLanguage = (selectedLanguage: string, i18n: any) => {
* @returns Settings * @returns Settings
*/ */
export function getDefaultSettings(): Settings { export function getDefaultSettings(): Settings {
return { return {
version: import.meta.env.SAFETWITCH_TAG, version: import.meta.env.SAFETWITCH_TAG,
settings: { settings: {
audioOnly: { audioOnly: {
name: 'audioOnly', name: 'audioOnly',
selected: false, selected: false,
type: 'checkbox' type: 'checkbox'
}, },
defaultQuality: { defaultQuality: {
name: 'defaultQuality', name: 'defaultQuality',
options: ['160p', '360p', '480p', '720p', '1080p'], options: ['160p', '360p', '480p', '720p', '1080p'],
selected: '480p', selected: '480p',
type: 'option' type: 'option'
}, },
language: { language: {
name: 'language', name: 'language',
options: languages, options: languages,
selected: 'English', selected: 'English',
type: 'option' type: 'option'
}, },
chatVisible: { chatVisible: {
name: 'chatVisible', name: 'chatVisible',
selected: false, selected: false,
type: 'checkbox' type: 'checkbox'
}, },
streamTagsVisible: { streamTagsVisible: {
name: 'streamTagsVisible', name: 'streamTagsVisible',
selected: true, selected: true,
type: 'checkbox' type: 'checkbox'
}, },
streamerAboutSectionVisible: { streamerAboutSectionVisible: {
name: 'streamerAboutSectionVisible', name: 'streamerAboutSectionVisible',
selected: true, selected: true,
type: 'checkbox' type: 'checkbox'
}, },
autoplay: { autoplay: {
name: 'autoplay', name: 'autoplay',
selected: false, selected: false,
type: 'checkbox' type: 'checkbox'
} }
}
} }
}
} }
/** /**
@ -93,50 +123,51 @@ export function getDefaultSettings(): Settings {
* @param settings Settings of the Settings type * @param settings Settings of the Settings type
* @returns The synced settings and a boolean stating if the settings were modified * @returns The synced settings and a boolean stating if the settings were modified
*/ */
export function syncUserSettings(settings: Settings): {settings: Settings, changed: boolean}{ export function syncUserSettings(settings: Settings): { settings: Settings; changed: boolean } {
const defaultSettings = getDefaultSettings() const defaultSettings = getDefaultSettings()
let userSettings = settings let userSettings = settings
// converting settings storage from versions older // converting settings storage from versions older
// than 2.4.1 // than 2.4.1
let oldMigration = false let oldMigration = false
if (userSettings.version === import.meta.env.SAFETWITCH_TAG) {
console.log('Settings up to date!')
return { settings: userSettings, changed: false }
} else {
console.log('Settings outdated... Migrating')
// converts v2.4.1 to 241
const settingsVersion = Number(
userSettings.version.slice(1, defaultSettings.version.length).split('.').join('')
)
if (userSettings.version === import.meta.env.SAFETWITCH_TAG) { if (settingsVersion < 241) {
console.log('Settings up to date!') oldMigration = true
return { settings: userSettings, changed: false } }
} else { }
console.log('Settings outdated... Migrating')
// converts v2.4.1 to 241
const settingsVersion = Number(userSettings.version.slice(1, defaultSettings.version.length).split(".").join(""))
if (settingsVersion < 241) { if (oldMigration) {
oldMigration = true const oldSettings: any = userSettings
} delete oldSettings.version
const migrated: Settings = {
version: defaultSettings.version,
settings: {
...oldSettings
}
} }
if (oldMigration) { userSettings = migrated
const oldSettings: any = userSettings }
delete oldSettings.version console.log(userSettings)
const migrated: Settings = {
version: defaultSettings.version,
settings: {
...oldSettings
}
}
userSettings = migrated const synced = { ...defaultSettings, ...userSettings }
}
console.log(userSettings)
const synced = { ...defaultSettings, ...userSettings } // update avaliable languages
synced.settings.language.options = defaultSettings.settings.language.options
// update avaliable languages synced.version = import.meta.env.SAFETWITCH_TAG
synced.settings.language.options = defaultSettings.settings.language.options localStorage.setItem('settings', JSON.stringify(synced))
synced.version = import.meta.env.SAFETWITCH_TAG console.log('Migrated!')
localStorage.setItem('settings', JSON.stringify(synced)) return { settings: synced, changed: true }
console.log('Migrated!')
return { settings: synced, changed: true }
} }
/** /**
@ -147,15 +178,15 @@ export function syncUserSettings(settings: Settings): {settings: Settings, chang
* getSetting("audioOnly") // false * getSetting("audioOnly") // false
*/ */
export function getSetting(key: string): boolean | string { export function getSetting(key: string): boolean | string {
const storage = localStorage.getItem('settings') const storage = localStorage.getItem('settings')
let parsed let parsed
if (!storage) { if (!storage) {
parsed = getDefaultSettings() parsed = getDefaultSettings()
} else { } else {
parsed = JSON.parse(storage) parsed = JSON.parse(storage)
} }
return parsed[key].selected return parsed[key].selected
} }
/** /**
@ -164,53 +195,53 @@ export function getSetting(key: string): boolean | string {
* @default string light * @default string light
*/ */
export function getTheme() { export function getTheme() {
return localStorage.getItem('theme') || "light" return localStorage.getItem('theme') || 'light'
} }
// every avaliable theme // every avaliable theme
export const themeList = [ export const themeList = [
{ {
// name your theme anything that could be a valid css class name // name your theme anything that could be a valid css class name
// remember what you named your theme because you will use it as a class to enable the theme // remember what you named your theme because you will use it as a class to enable the theme
name: 'dark', name: 'dark',
// put any overrides your theme has here // put any overrides your theme has here
// just as if you were to extend tailwind's theme like normal https://tailwindcss.com/docs/theme#extending-the-default-theme // just as if you were to extend tailwind's theme like normal https://tailwindcss.com/docs/theme#extending-the-default-theme
extend: { extend: {
colors: { colors: {
"primary": '#141515', primary: '#141515',
"secondary": '#1e1f1f', secondary: '#1e1f1f',
"overlay0": '#282a2a', overlay0: '#282a2a',
"overlay1": '#323434', overlay1: '#323434',
"surface0": '#393B3B', surface0: '#393B3B',
"surface1": '#3F4242', surface1: '#3F4242',
"crust": '#0C0C0C', crust: '#0C0C0C',
"purple": '#D946EF', purple: '#D946EF',
"red": "#980C0C", red: '#980C0C',
"neutral": "#bdbdbd", neutral: '#bdbdbd',
"contrast": "white", contrast: 'white'
} }
}
},
{
// name your theme anything that could be a valid css class name
// remember what you named your theme because you will use it as a class to enable the theme
name: 'light',
// put any overrides your theme has here
// just as if you were to extend tailwind's theme like normal https://tailwindcss.com/docs/theme#extending-the-default-theme
extend: {
colors: {
"primary": '#ebeaea',
"secondary": '#e1e0e0',
"overlay0": '#d7d5d5',
"overlay1": '#cdcbcb',
"surface0": '#c6c4c4',
"surface1": '#c0bdbd',
"crust": '#fafafa',
"purple": '#D946EF',
"red": "#e81304",
"neutral": "gray",
"contrast": "black",
}
}
} }
},
{
// name your theme anything that could be a valid css class name
// remember what you named your theme because you will use it as a class to enable the theme
name: 'light',
// put any overrides your theme has here
// just as if you were to extend tailwind's theme like normal https://tailwindcss.com/docs/theme#extending-the-default-theme
extend: {
colors: {
primary: '#ebeaea',
secondary: '#e1e0e0',
overlay0: '#d7d5d5',
overlay1: '#cdcbcb',
surface0: '#c6c4c4',
surface1: '#c0bdbd',
crust: '#fafafa',
purple: '#D946EF',
red: '#e81304',
neutral: 'gray',
contrast: 'black'
}
}
}
] ]

View File

@ -98,10 +98,9 @@ export default {
<ul class="mb-5"> <ul class="mb-5">
<li v-for="tag in data.tags" :key="tag" class="inline-flex"> <li v-for="tag in data.tags" :key="tag" class="inline-flex">
<span <span class="p-1 py-0.5 mr-1 text-sm font-bold bg-overlay1 rounded-sm">{{
class="p-1 py-0.5 mr-1 text-sm font-bold bg-overlay1 rounded-sm" tag
>{{ tag }}</span }}</span>
>
</li> </li>
</ul> </ul>
</div> </div>
@ -125,10 +124,9 @@ export default {
<ul class="mb-5"> <ul class="mb-5">
<li v-for="tag in data.tags" :key="tag" class="inline-flex"> <li v-for="tag in data.tags" :key="tag" class="inline-flex">
<span <span class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-overlay1 rounded-sm">{{
class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-overlay1 rounded-sm" tag
>{{ tag }}</span }}</span>
>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -30,7 +30,7 @@ export default {
status.value = 'error' status.value = 'error'
}) })
console.log(srcUrl) console.log(srcUrl)
const videoOptions = { const videoOptions = {
autoplay: true, autoplay: true,
@ -96,8 +96,7 @@ export default {
class="flex bg-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-contrast" class="flex bg-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-contrast"
> >
<div class="w-full mx-auto rounded-lg mb-5"> <div class="w-full mx-auto rounded-lg mb-5">
<video-player :options="videoOptions"> <video-player :options="videoOptions"> </video-player>
</video-player>
</div> </div>
<div class="w-full flex-wrap md:p-3"> <div class="w-full flex-wrap md:p-3">
@ -122,23 +121,23 @@ export default {
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="pt-2 inline-flex"> <div class="pt-2 inline-flex">
<follow-button :username="data.streamer.username"></follow-button> <follow-button :username="data.streamer.username"></follow-button>
<p class="align-baseline font-bold ml-3"> <p class="align-baseline font-bold ml-3">
{{ abbreviate(data.streamer.followers) }} {{ $t('main.followers') }} {{ abbreviate(data.streamer.followers) }} {{ $t('main.followers') }}
</p> </p>
</div> </div>
<div class="space-x-1"> <div class="space-x-1">
<a :href="srcUrl" download> <a :href="srcUrl" download>
<button class="px-2 py-1.5 rounded-lg bg-purple"> <button class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="md-download-round"></v-icon> <v-icon name="md-download-round"></v-icon>
</button> </button>
</a> </a>
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple"> <button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="fa-share-alt"></v-icon> <v-icon name="fa-share-alt"></v-icon>
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -102,11 +102,7 @@ export default {
<h1 class="font-bold text-5xl">Following</h1> <h1 class="font-bold text-5xl">Following</h1>
<p class="text-xl">Streamers you follow</p> <p class="text-xl">Streamers you follow</p>
<ul class="flex overflow-x-scroll space-x-2 flex-nowrap h-[22rem] items-center"> <ul class="flex overflow-x-scroll space-x-2 flex-nowrap h-[22rem] items-center">
<li <li v-for="streamer in following" :key="streamer" class="inline-block">
v-for="streamer in following"
:key="streamer"
class="inline-block"
>
<stream-preview-vue :name="streamer"></stream-preview-vue> <stream-preview-vue :name="streamer"></stream-preview-vue>
</li> </li>
</ul> </ul>

View File

@ -18,7 +18,7 @@ export default {
settings = syncResp.settings settings = syncResp.settings
} }
let selectedTheme = localStorage.getItem('theme') || "light" let selectedTheme = localStorage.getItem('theme') || 'light'
return { return {
settings, settings,
@ -35,7 +35,7 @@ export default {
this.setTheme() this.setTheme()
// Reload needed // Reload needed
location.href = "/" location.href = '/'
}, },
setTheme() { setTheme() {
localStorage.setItem('theme', this.selectedTheme) localStorage.setItem('theme', this.selectedTheme)
@ -44,15 +44,15 @@ export default {
if (this.selectedTheme == theme) { if (this.selectedTheme == theme) {
return 'border-purple' return 'border-purple'
} }
return "border-none" return 'border-none'
}, },
download() { download() {
var hiddenElement = document.createElement('a'); var hiddenElement = document.createElement('a')
hiddenElement.href = 'data:attachment/text,' + encodeURI(JSON.stringify(this.settings)); hiddenElement.href = 'data:attachment/text,' + encodeURI(JSON.stringify(this.settings))
hiddenElement.target = '_blank'; hiddenElement.target = '_blank'
hiddenElement.download = 'safetwitch_prefs.json'; hiddenElement.download = 'safetwitch_prefs.json'
hiddenElement.click(); hiddenElement.click()
}, },
async handleImport(event: any) { async handleImport(event: any) {
const file = await event.target.files[0].text() const file = await event.target.files[0].text()
@ -68,14 +68,14 @@ export default {
this.settings = settings this.settings = settings
this.save() this.save()
}, }
} }
} }
</script> </script>
<template> <template>
<div class="mx-auto w-[35rem] max-w-[95vw] p-5 py-3 bg-secondary rounded-md text-contrast"> <div class="mx-auto w-[35rem] max-w-[95vw] p-5 py-3 bg-secondary rounded-md text-contrast">
<h1 class="font-bold text-3xl">{{ $t("nav.settings") }}</h1> <h1 class="font-bold text-3xl">{{ $t('nav.settings') }}</h1>
<hr class="my-2" /> <hr class="my-2" />
<ul class="w-full space-y-1"> <ul class="w-full space-y-1">
<li v-for="setting in settings.settings" :key="setting.type"> <li v-for="setting in settings.settings" :key="setting.type">
@ -86,8 +86,12 @@ export default {
<div v-else-if="setting.type == 'option'" class="justify-between items-center flex"> <div v-else-if="setting.type == 'option'" class="justify-between items-center flex">
<label :for="setting.name">{{ $t(`settings.${setting.name}`) }}</label> <label :for="setting.name">{{ $t(`settings.${setting.name}`) }}</label>
<select :name="setting.name" type="checkbox" v-model="setting.selected" <select
class="text-black rounded-md h-8 p-0 pr-8 pl-2"> :name="setting.name"
type="checkbox"
v-model="setting.selected"
class="text-black rounded-md h-8 p-0 pr-8 pl-2"
>
<option v-for="option of setting.options" :key="option" :value="option"> <option v-for="option of setting.options" :key="option" :value="option">
{{ option }} {{ option }}
</option> </option>
@ -96,25 +100,40 @@ export default {
</li> </li>
</ul> </ul>
<h1 class="font-bold text-3xl mt-2">{{ $t("main.themes") }}</h1> <h1 class="font-bold text-3xl mt-2">{{ $t('main.themes') }}</h1>
<hr class="my-2" /> <hr class="my-2" />
<ul class="flex space-x-2 "> <ul class="flex space-x-2">
<!-- <!--
Use theme colors for preview Use theme colors for preview
--> -->
<li v-for="theme in themeList" :key="theme.name" class="hover:scale-110 border-2 rounded-md transition-transform" <li
:class="highlight(theme.name)"> v-for="theme in themeList"
<button @click="selectedTheme = theme.name" class="p-5 py-1.5 border-4 rounded-md" :key="theme.name"
:style="`border-color: ${theme.extend.colors.primary}; background:${theme.extend.colors.crust}; color:${theme.extend.colors.contrast};`"> class="hover:scale-110 border-2 rounded-md transition-transform"
:class="highlight(theme.name)"
>
<button
@click="selectedTheme = theme.name"
class="p-5 py-1.5 border-4 rounded-md"
:style="`border-color: ${theme.extend.colors.primary}; background:${theme.extend.colors.crust}; color:${theme.extend.colors.contrast};`"
>
<p>{{ theme.name }}</p> <p>{{ theme.name }}</p>
</button> </button>
</li> </li>
</ul> </ul>
<div class="space-x-2 mt-3"> <div class="space-x-2 mt-3">
<button @click="save" class="bg-surface0 p-4 py-2 rounded-md">{{ $t('settings.saveButton') }}</button> <button @click="save" class="bg-surface0 p-4 py-2 rounded-md">
{{ $t('settings.saveButton') }}
</button>
<button @click="download" class="bg-surface0 p-4 py-2 rounded-md">Export</button> <button @click="download" class="bg-surface0 p-4 py-2 rounded-md">Export</button>
<input type="file" @change="handleImport" name="fileinput" ref="fileinput" class="bg-surface0 p-4 py-2 rounded-md"> <input
type="file"
@change="handleImport"
name="fileinput"
ref="fileinput"
class="bg-surface0 p-4 py-2 rounded-md"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -12,7 +12,6 @@ import AudioPlayer from '@/components/AudioPlayer.vue'
import AboutTab from '@/components/user/AboutTab.vue' import AboutTab from '@/components/user/AboutTab.vue'
import ShareModal from '@/components/popups/ShareButtonModal.vue' import ShareModal from '@/components/popups/ShareButtonModal.vue'
import type { StreamerData } from '@/types' import type { StreamerData } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
import { chatVisible, getSetting } from '@/settingsManager' import { chatVisible, getSetting } from '@/settingsManager'
@ -26,7 +25,7 @@ export default {
const status = ref<'ok' | 'error'>() const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl') const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = { const videoOptions = {
autoplay: getSetting("autoplay"), autoplay: getSetting('autoplay'),
controls: true, controls: true,
sources: [ sources: [
{ {
@ -48,9 +47,9 @@ export default {
}, },
async mounted() { async mounted() {
// check if audio only // check if audio only
const audioOnly = getSetting("audioOnly") const audioOnly = getSetting('audioOnly')
if (audioOnly) { if (audioOnly) {
this.$router.push({ query: { "audio-only": "true" } }) this.$router.push({ query: { 'audio-only': 'true' } })
} }
const username = this.$route.params.username const username = this.$route.params.username
@ -87,7 +86,12 @@ export default {
</script> </script>
<template> <template>
<share-modal v-if="shareModalVisible" :time="0" :useTime="false" @close="toggleShareModal"></share-modal> <share-modal
v-if="shareModalVisible"
:time="0"
:useTime="false"
@close="toggleShareModal"
></share-modal>
<loading-screen v-if="!data && status != 'error'"></loading-screen> <loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message> <error-message v-else-if="status == 'error'"></error-message>
@ -99,7 +103,8 @@ export default {
class="flex bg-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-contrast" class="flex bg-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-contrast"
> >
<div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5"> <div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5">
<video-player v-if="Boolean($route.query['audio-only']) === false" :options="videoOptions"> </video-player> <video-player v-if="Boolean($route.query['audio-only']) === false" :options="videoOptions">
</video-player>
<audio-player v-else :masterManifestUrl="audioOptions"></audio-player> <audio-player v-else :masterManifestUrl="audioOptions"></audio-player>
</div> </div>
@ -176,7 +181,7 @@ export default {
</p> </p>
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple"> <button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="fa-share-alt"></v-icon> <v-icon name="fa-share-alt"></v-icon>
</button> </button>
</div> </div>
</div> </div>

View File

@ -10,7 +10,6 @@ import LoadingScreen from '@/components/LoadingScreen.vue'
import AboutTab from '@/components/user/AboutTab.vue' import AboutTab from '@/components/user/AboutTab.vue'
import ShareModal from '@/components/popups/ShareButtonModal.vue' import ShareModal from '@/components/popups/ShareButtonModal.vue'
import type { Video } from '@/types' import type { Video } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
import { chatVisible, getSetting } from '@/settingsManager' import { chatVisible, getSetting } from '@/settingsManager'
@ -83,7 +82,12 @@ export default {
</script> </script>
<template> <template>
<share-modal v-if="shareModalVisible" :time="time" :useTime="true" @close="toggleShareModal"></share-modal> <share-modal
v-if="shareModalVisible"
:time="time"
:useTime="true"
@close="toggleShareModal"
></share-modal>
<loading-screen v-if="!data && status != 'error'"></loading-screen> <loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message> <error-message v-else-if="status == 'error'"></error-message>
@ -127,10 +131,9 @@ export default {
</p> </p>
</div> </div>
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple">
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple"> <v-icon name="fa-share-alt"></v-icon>
<v-icon name="fa-share-alt"></v-icon> </button>
</button>
</div> </div>
</div> </div>