Compare commits

..

10 Commits

Author SHA1 Message Date
ngn
58e9b94868
general cleanup
Some checks failed
Build and publish the docker image / build (push) Failing after 1m16s
Signed-off-by: ngn <ngn@ngn.tf>
2025-01-21 10:20:42 +03:00
dragongoose
f274ba527d Merge pull request 'Theatre Mode' (#129) from Splashy5/safetwitch:master into master
Reviewed-on: https://codeberg.org/SafeTwitch/safetwitch/pulls/129
Reviewed-by: dragongoose <dragongoose@noreply.codeberg.org>
2024-08-17 19:31:24 +00:00
Seraph91P
7b7b51a9f5 (theatre mode): moved styling to css file 2024-08-14 09:29:59 +02:00
Seraph91P
4985f3084e feat(user): changed tags to be more responisve and made room for the action buttons 2024-08-13 11:29:36 +02:00
Seraph91P
ad0578d2c0 feat(theatremode): added new action buttons 2024-08-10 13:28:46 +02:00
Seraph91P
467f16824c feat(vod): added handlePlayerTimeUpdate back 2024-08-10 13:28:13 +02:00
Seraph91P
43739a3537 feat(action-buttons): added new component for all action buttons 2024-08-10 13:27:08 +02:00
Seraph91P
ca7b639a45 feat(video): added button to actuall call theatre mode 2024-08-06 19:57:04 +02:00
Seraph91P
2681bfe10f feat(video): added theatre mode 2024-08-06 19:16:46 +02:00
dragongoose
caeb85a77b
Change woodpecker definition 2024-07-13 13:09:46 -04:00
24 changed files with 254 additions and 451 deletions

View File

@ -0,0 +1,28 @@
name: Build and publish the docker image
on:
push:
branches: ["custom"]
env:
REGISTRY: git.ngn.tf
IMAGE: ${{gitea.repository}}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: "https://github.com/actions/checkout@v4"
- name: Login to container repo
uses: "https://github.com/docker/login-action@v1"
with:
registry: ${{env.REGISTRY}}
username: ${{gitea.actor}}
password: ${{secrets.PACKAGES_TOKEN}}
- name: Build image
run: |
docker build . --tag ${{env.REGISTRY}}/${{env.IMAGE}}:latest
docker push ${{env.REGISTRY}}/${{env.IMAGE}}:latest

View File

@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@ -1,32 +0,0 @@
steps:
lint:
image: docker.io/node:16
commands:
- git clone --recurse-submodules -j8 https://codeberg.org/${CI_REPO_OWNER}/safetwitch
- npm i
- npm run lint
when:
event: push
branch: master
build:
image: docker.io/node:16
commands:
- npm run build
when:
event: push
branch: master
publish:
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: ./docker/Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
registry: codeberg.org
auto_tag: true
repo: codeberg.org/safetwitch/safetwitch
username: safetwitch
password:
from_secret: cb_token
when:
event: tag

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM docker.io/node:16 AS builder
ENV SAFETWITCH_BACKEND_DOMAIN SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER
ENV SAFETWITCH_INSTANCE_DOMAIN SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER
ENV SAFETWITCH_HTTPS SAFETWITCH_HTTPS_PLACEHOLDER
ENV SAFETWITCH_DEFAULT_LOCALE SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER
ENV SAFETWITCH_FALLBACK_LOCALE SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER
ENV SAFETWITCH_DEFAULT_THEME SAFETWITCH_DEFAULT_THEME_PLACEHOLDER
WORKDIR /app
COPY ./ .
RUN npm i
RUN npm run build
FROM docker.io/nginx:alpine
COPY ./docker/nginx.conf /etc/nginx/nginx.conf
WORKDIR /app
COPY --from=builder /app/dist ./
COPY ./docker/init.sh /init.sh
RUN chmod +x /init.sh
ENTRYPOINT ["/init.sh"]

127
README.md
View File

@ -1,126 +1,5 @@
# SafeTwitch # [ngn.tf] | safetwitch
<a href="https://translate.codeberg.org/engage/safetwitch/"> ![](https://git.ngn.tf/ngn/safetwitch/actions/workflows/build.yml/badge.svg)
<img src="https://translate.codeberg.org/widget/safetwitch/frontend/svg-badge.svg" alt="Translation status" />
</a>
SafeTwitch is a privacy respecting frontend for [twitch.tv](https://twitch.tv/) A fork of the [safetwitch](https://codeberg.org/safetwitch/safetwitch) project, with my personal changes.
The main advantages of SafeTwitch are:
- Private: Every request is proxied through the server, and no logs are kept.
- Lightweight: Compared to twitch, SafeTwitch is optimized for speed and usability.
</br>
You can find an instance to use [here](#instances)!
Do you want to help translate? You can do it over here on weblate! [Translate](#translate)
# Okay, but why?
It is impossible to use Twitch without being bombarded with tons of ads, multiple trackers, and enourmous page sizes and loading times. This project aims to fix these issues, by removing all trackers, have much smaller page sizes, and very fast loading times.
# Features
### User features
- [x] No connection to twitch/amazon
- [x] Lightweight on server and client
- [x] No Ads or tracking
- [x] No outside connections, only connection is the instance
- [x] Uses [Vue](https://vuejs.org/) for a speedy experience
- [x] No logs
- [x] Much smaller pages compared to Twitch (<1.6mb with images compared to >8.2mb)
- [x] Follow streamers locally to have a more personalized feel
- [x] Infinite scrolling
- [x] Proxied WebSocket IRC
### Technical features
- [x] Public API
- [x] No official APIs are used
- [x] No rate limiting
- [x] Uses a custom Twitch webscraper
It's not all sunshine and rainbows though, and still has various cons, including
- SafeTwitch was a learning project
- Uses Vue, which relies on Javascript
You aren't forced to use SafeTwitch, so use whatever suits you the most!
Heres some other notable twitch projects
- [Xtra](https://f-droid.org/packages/com.github.andreyasadchy.xtra/), a Twitch client focused on providing the best viewing and chatting experience on mobile devices
- [Twire](https://f-droid.org/packages/com.perflyst.twire/), an ad free Twitch browser and stream player for Android.
- [Streamlink Twitch Gui](https://streamlink.github.io/streamlink-twitch-gui/), A multi platform Twitch.tv browser for Streamlink
- [Twineo](https://codeberg.org/CloudyyUw/twineo), A alternative twitch frontend
# Screenshots
| Images | More Images |
| --------------------------------------------------- | --------------------------------------------------- |
| ![ Photo of stream ](images/home.png "title") | ![ Photo of stream ](images/stream.png "title") |
| ![ Photo of category ](images/category.png "title") | ![ Photo of streamer ](images/streamer.png "title") |
# Donations
Donations towards development are not accepted. I really thank you for feeling the need to donate, it does mean a lot to me!
Instead, please donate your money to one of these charities which mean a lot to me.
- [American Foundation for Suicide Prevention](https://afsp.org/)
- [Boys and Girls Club of America](https://www.bgca.org/ways-to-give)
# Getting Started
All documentation can be found on the [wiki](https://codeberg.org/SafeTwitch/safetwitch/wiki)
## Translate
<a href="https://translate.codeberg.org/engage/safetwitch/">
<img src="https://translate.codeberg.org/widgets/safetwitch/-/frontend/multi-auto.svg" alt="Translation status" />
</a>
Translating is a great way to help contribute! Even if it's only one word, anything helps!
You can translate here: https://translate.codeberg.org/projects/safetwitch/frontend/
## Instances
If you host a SafeTwitch instance and would like it to be listed in the readme, please make an issue or a pull request to add it in.
### Clearnet
| URL | Country | Info | Cloudflare |
|-----------------------------------------------------------------------------|----------------|----------------------------------------------------------------------------------------------------| ---------- |
| [safetwitch.drgns.space \(Official\)](https://safetwitch.drgns.space/) | 🇺🇸 | Homelab | ❌ |
| [safetwitch.projectsegfau.lt](https://safetwitch.projectsegfau.lt/) | 🇺🇸 🇮🇳 🇩🇪 | #2 | ❌ |
| [stream.whateveritworks.org](https://stream.whateveritworks.org) | 🇩🇪 | Hosted on Hetzner/Dedicated Server with Encryption at rest | ✅ |
| [safetwitch.datura.network](https://safetwitch.datura.network) | 🇩🇪 | #9 | ❌ |
| [ttv.vern.cc](https://ttv.vern.cc) | 🇺🇸 | #12 | ❌ |
| [safetwitch.frontendfriendly.xyz](https://safetwitch.frontendfriendly.xyz/) | 🇺🇸 | #16 | ❌ |
| [ttv.femboy.band](https://ttv.femboy.band) | 🇩🇪 | #29 | ❌ |
| [twitch.seitan-ayoub.lol](https://twitch.seitan-ayoub.lol) | 🇩🇪 | Hetnzer VPS | ❌ |
| [st.ggtyler.dev](https://st.ggtyler.dev) | 🇺🇸 | [See ggtyler's frontend list for more info and locations](https://www.ggtyler.dev/other/frontends) | ❌ |
| [safetwitch.lunar.icu](https://safetwitch.lunar.icu) | 🇩🇪 | [See lunar.icu's site for more info](https://lunar.icu) | ❌ |
| [twitch.sudovanilla.org](https://twitch.sudovanilla.org) | 🇺🇸 | Selfhosted | ❌ |
| [safetwitch.r4fo.com](https://safetwitch.r4fo.com) | 🇩🇪 | #80 | ✅ |
| [safetwitch.ducks.party](https://safetwitch.ducks.party) | 🇳🇱 | Timeweb VPS | ❌ |
| [safetwitch.nogafam.fr](https://safetwitch.nogafam.fr) | 🇫🇷 | [See NoGafam services](https://nogafam.fr) | ❌ |
| [safetwitch.privacyredirect.com](https://safetwitch.privacyredirect.com/) | 🇫🇮 | #98 | ❌ |
| [st.ngn.tf](https://st.ngn.tf/) | 🇹🇷 | Selfhosted | ❌ |
| [safetwitch.darkness.services](https://safetwitch.darkness.services) | 🇺🇸 | #119 | ✅ |
| [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 | ❌ |
### Onion
| URL | Country | Info |
| ------------------------------------------------------------------------------------------ | ------- | ---- |
| [Onion vern.cc](http://ttv.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | 🇺🇸 | #12 |
### I2P
| URL | Country | Info |
| ---------------------------------------------------------------------------------- | ------- | ---- |
| [i2p vern.cc](http://vernz43kgqiy3nzzof3nejeo4hh3bjgyqi3b3hijchilv7noqtrq.b32.i2p) | 🇺🇸 | #12 |

View File

@ -1,24 +0,0 @@
server {
server_name changethis;
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/changethis/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/changethis/privkey.pem;
location / {
proxy_pass http://localhost:7100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}
server {
listen 80;
listen [::]:80;
server_name changethis;
return 301 https://changethis$request_uri;
}

View File

@ -1,21 +0,0 @@
server {
server_name changethis;
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/changethis/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/changethis/privkey.pem;
location / {
proxy_pass http://localhost:8280;
}
}
server {
listen 80;
listen [::]:80;
server_name changethis;
return 301 https://changethis$request_uri;
}

View File

@ -1,41 +0,0 @@
# Multi-stage
# 1) Node image for building frontend assets
# 2) nginx stage to serve frontend assets
# Name the node stage "builder"
FROM docker.io/node:16 AS builder
# Set working directory
WORKDIR /app
# Filled with placeholders, later changed and managed
# by the substitute_environment_variables.sh file
ENV SAFETWITCH_BACKEND_DOMAIN SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER
ENV SAFETWITCH_INSTANCE_DOMAIN SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER
ENV SAFETWITCH_HTTPS SAFETWITCH_HTTPS_PLACEHOLDER
ENV SAFETWITCH_DEFAULT_LOCALE SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER
ENV SAFETWITCH_FALLBACK_LOCALE SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER
ENV SAFETWITCH_DEFAULT_THEME SAFETWITCH_DEFAULT_THEME_PLACEHOLDER
# Copy all files from current directory to working dir in image
COPY ./ .
RUN ls
# install node modules and build assets
RUN npm i && npm run build
# nginx state for serving content
FROM docker.io/nginx:alpine
COPY ./docker/nginx.conf /etc/nginx/nginx.conf
# Set working directory to nginx asset directory
RUN mkdir /app
# Copy static assets from builder stage
COPY --from=builder /app/dist /app
# Containers run nginx with global directives and daemon off
EXPOSE 8280
# Overriding the default NGINX container behavior
COPY ./docker/substitute_environment_variables.sh ./substitute_environment_variables.sh
RUN chmod +x /substitute_environment_variables.sh
ENTRYPOINT ["/substitute_environment_variables.sh"]

View File

@ -1,35 +0,0 @@
version: "3.7"
services:
safetwitch-frontend:
container_name: safetwitch-frontend
hostname: safetwitch-frontend
restart: always
build:
context: "../"
dockerfile: "./docker/Dockerfile"
ports:
- "127.0.0.1:8280:8280"
environment:
- SAFETWITCH_BACKEND_DOMAIN=localhost:7100
- SAFETWITCH_INSTANCE_DOMAIN=localhost:8280
- SAFETWITCH_HTTPS=true
- SAFETWITCH_DEFAULT_THEME=dark
- SAFETWITCH_DEFAULT_LOCALE=en-US
- SAFETWITCH_FALLBACK_LOCALE=en-US
safetwitch-backend:
container_name: safetwitch-backend
hostname: safetwitch-backend
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:7100:7000"
environment:
- PORT=7000
- URL=changeme

View File

@ -1,32 +1,32 @@
version: "3.7"
services: services:
safetwitch-frontend: st_frontend:
container_name: safetwitch-frontend container_name: safetwitch_frontend
hostname: safetwitch-frontend image: git.ngn.tf/ngn/safetwitch
restart: always security_opt:
image: codeberg.org/safetwitch/safetwitch:latest - no-new-privileges:true
cap_drop:
- ALL
ports: ports:
- "127.0.0.1:8280:8280" - 8080:8280
environment: environment:
- SAFETWITCH_BACKEND_DOMAIN=changeme - SAFETWITCH_BACKEND_DOMAIN=localhost:8081
- SAFETWITCH_INSTANCE_DOMAIN=changeme - SAFETWITCH_INSTANCE_DOMAIN=localhost:8080
- SAFETWITCH_HTTPS=true - SAFETWITCH_HTTPS=false
- SAFETWITCH_DEFAULT_LOCALE=en - SAFETWITCH_DEFAULT_LOCALE=en
- SAFETWITCH_FALLBACK_LOCALE=en - SAFETWITCH_FALLBACK_LOCALE=en
restart: unless-stopped
safetwitch-backend: st_backend:
container_name: safetwitch-backend container_name: safetwitch_backend
hostname: safetwitch-backend image: codeberg.org/safetwitch/safetwitch-backend:latest
read_only: true read_only: true
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
restart: always
image: codeberg.org/safetwitch/safetwitch-backend:latest
ports: ports:
- "127.0.0.1:7100:7000" - 8081:7000
environment: environment:
- PORT=7000 - PORT=7000
- URL=changeme - URL=http://localhost:8081
restart: unless-stopped

View File

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -1,3 +1,15 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.content-container {
@apply flex bg-crust flex-col p-6 rounded-lg w-full text-contrast;
}
.content-container-theatre {
@apply md:w-[62vw] lg:w-[75vw] xl:w-[75vw] 2xl:w-[68vw];
}
.content-container-normal {
@apply md:w-[70vw] lg:w-[70vw] xl:w-[60vw] 2xl:w-[50vw] max-w-[1200px];
}

View File

@ -0,0 +1,28 @@
<template>
<div class="space-x-1">
<a v-if="showDownload" :href="downloadUrl" download>
<button class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="md-download-round"></v-icon>
</button>
</a>
<button v-if="showTheatreMode" @click="$emit('toggleTheatreMode')" class="hidden xl:inline px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="fa-expand"></v-icon>
</button>
<button v-if="showShare" @click="$emit('toggleShareModal')" class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="fa-share-alt"></v-icon>
</button>
</div>
</template>
<script lang="ts">
export default {
props: {
showDownload: Boolean,
showTheatreMode: Boolean,
showShare: Boolean,
downloadUrl: String
},
emits: ['toggleTheatreMode', 'toggleShareModal']
}
</script>

View File

@ -24,7 +24,7 @@ export default {
</script> </script>
<template> <template>
<div class="bg-crust w-40 lg:w-[11rem] md:w-[13.5rem] rounded-lg"> <div class="bg-crust w-40 h-full lg:w-[11rem] md:w-[13.5rem] rounded-lg flex flex-col justify-between">
<router-link :to="`/directory/game/${encodeURIComponent(category.name)}`"> <router-link :to="`/directory/game/${encodeURIComponent(category.name)}`">
<img :src="category.image" class="rounded-lg rounded-b-none w-full" /> <img :src="category.image" class="rounded-lg rounded-b-none w-full" />
</router-link> </router-link>

View File

@ -32,12 +32,11 @@ export default {
</router-link> </router-link>
</div> </div>
<search-bar class="mt-4 mr-4 hidden md:inline-block sm:mt-0"></search-bar> <search-bar class="mt-4 mr-4 hidden md:inline-block sm:mt-0 grow"></search-bar>
<div class="text-contrast hidden space-x-4 md:block"> <div class="text-contrast hidden md:block">
<a href="https://codeberg.org/safetwitch/safetwitch" target="_blank">{{ $t('nav.code') }}</a> <a href="https://codeberg.org/safetwitch/safetwitch" target="_blank">{{ $t('nav.code') }}</a>
<a :href="'https://twitch.tv' + $route.fullPath">Twitch</a> <a href="https://git.ngn.tf/ngn/safetwitch">Modified Code</a>
<router-link to="/privacy">{{ $t('nav.privacy') }}</router-link>
<router-link to="/following">{{ $t('home.following') }}</router-link> <router-link to="/following">{{ $t('home.following') }}</router-link>
<router-link to="/settings">{{ $t('nav.settings') }}</router-link> <router-link to="/settings">{{ $t('nav.settings') }}</router-link>
</div> </div>
@ -60,8 +59,8 @@ export default {
<search-bar></search-bar> <search-bar></search-bar>
<ul class="inline-flex space-x-3 md:space-x-6 font-medium"> <ul class="inline-flex space-x-3 md:space-x-6 font-medium">
<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://git.ngn.tf/ngn/safetwitch">Modified 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="/following">{{ $t('home.following') }}</router-link> <router-link to="/following">{{ $t('home.following') }}</router-link>
<router-link to="/settings">{{ $t('nav.settings') }}</router-link> <router-link to="/settings">{{ $t('nav.settings') }}</router-link>
</ul> </ul>
@ -69,3 +68,15 @@ export default {
</div> </div>
</nav> </nav>
</template> </template>
<style>
a:hover, router-link:hover {
text-decoration: underline;
}
a + a::before {
content: "|";
margin: 0 8px;
font-weight: 100;
}
</style>

View File

@ -25,7 +25,7 @@ export default {
:placeholder="$t('main.search')" :placeholder="$t('main.search')"
v-model="searchInput" v-model="searchInput"
@keyup.enter="redirectToSearch" @keyup.enter="redirectToSearch"
class="rounded-md p-1 pl-8 w-56 text-black bg-white placeholder:text-black" class="rounded-md p-1 pl-8 w-56 text-white bg-black border-white outline-none placeholder:text-white w-full"
/> />
</div> </div>
</template> </template>

View File

@ -36,7 +36,8 @@ import {
FaShareAlt, FaShareAlt,
IoCloseSharp, IoCloseSharp,
MdDownloadRound, MdDownloadRound,
IoPerson IoPerson,
FaExpand
} from 'oh-vue-icons/icons' } from 'oh-vue-icons/icons'
addIcons( addIcons(
@ -55,7 +56,8 @@ addIcons(
FaShareAlt, FaShareAlt,
IoCloseSharp, IoCloseSharp,
MdDownloadRound, MdDownloadRound,
IoPerson IoPerson,
FaExpand
) )
app.component('v-icon', OhVueIcon) app.component('v-icon', OhVueIcon)

View File

@ -244,7 +244,7 @@ export const themeList = [
// 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: '#000',
secondary: '#1e1f1f', secondary: '#1e1f1f',
overlay0: '#282a2a', overlay0: '#282a2a',
overlay1: '#323434', overlay1: '#323434',

View File

@ -9,11 +9,11 @@ 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 VueTitle from '@/components/VueTitle.vue' import VueTitle from '@/components/VueTitle.vue'
import ActionButtons from '@/components/ActionButtons.vue'
import type { Video } from '@/types' import type { Video } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
export default { export default {
inject: ['rootBackendUrl'], inject: ['rootBackendUrl'],
async setup() { async setup() {
@ -21,6 +21,7 @@ export default {
const clipSlug = route.params.slug const clipSlug = route.params.slug
const data = ref<Video>() const data = ref<Video>()
const status = ref<'ok' | 'error'>() const status = ref<'ok' | 'error'>()
const isTheatreMode = ref(false)
let srcUrl let srcUrl
await getEndpoint(`api/clips/cliplink/${clipSlug}`) await getEndpoint(`api/clips/cliplink/${clipSlug}`)
@ -31,8 +32,6 @@ export default {
status.value = 'error' status.value = 'error'
}) })
console.log(srcUrl)
const videoOptions = { const videoOptions = {
autoplay: true, autoplay: true,
controls: true, controls: true,
@ -51,7 +50,8 @@ export default {
videoOptions, videoOptions,
time: ref(0), time: ref(0),
srcUrl, srcUrl,
shareModalVisible: ref(false) shareModalVisible: ref(false),
isTheatreMode
} }
}, },
async mounted() { async mounted() {
@ -72,13 +72,17 @@ export default {
LoadingScreen, LoadingScreen,
AboutTab, AboutTab,
ShareModal, ShareModal,
VueTitle VueTitle,
ActionButtons
}, },
methods: { methods: {
truncate, truncate,
abbreviate, abbreviate,
toggleShareModal() { toggleShareModal() {
this.shareModalVisible = !this.shareModalVisible this.shareModalVisible = !this.shareModalVisible
},
toggleTheatreMode() {
this.isTheatreMode = !this.isTheatreMode
} }
} }
} }
@ -95,7 +99,10 @@ export default {
> >
<VueTitle :title=" 'Clip - ' + data.title"></VueTitle> <VueTitle :title=" 'Clip - ' + data.title"></VueTitle>
<div <div
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="[
'content-container',
isTheatreMode ? 'content-container-theatre' : 'content-container-normal'
]"
> >
<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> <video-player :options="videoOptions"> </video-player>
@ -129,17 +136,14 @@ export default {
</p> </p>
</div> </div>
<div class="space-x-1"> <action-buttons
<a :href="srcUrl" download> :showDownload="true"
<button class="px-2 py-1.5 rounded-lg bg-purple"> :showTheatreMode="true"
<v-icon name="md-download-round"></v-icon> :showShare="true"
</button> @toggleTheatreMode="toggleTheatreMode"
</a> @toggleShareModal="toggleShareModal"
:downloadUrl="srcUrl"
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple"> />
<v-icon name="fa-share-alt"></v-icon>
</button>
</div>
</div> </div>
</div> </div>
@ -147,4 +151,4 @@ export default {
<about-tab :socials="data.streamer.socials" :about="data.streamer.about"></about-tab> <about-tab :socials="data.streamer.socials" :about="data.streamer.about"></about-tab>
</div> </div>
</div> </div>
</template> </template>

View File

@ -21,40 +21,11 @@ export default {
return { return {
data, data,
status, status,
filterTags: '',
following, following,
followingStreaming followingStreaming
} }
}, },
methods: { methods: {
filterSearches(toFilter: string) {
const categories = this.$refs.categoryItem
const wantedTags: string[] = toFilter
.toLowerCase()
.split(',')
.filter((v) => v.toLowerCase())
for (let category of categories as any) {
let tagElements = category.getElementsByTagName('span')
let tags = []
for (let tag of tagElements) {
tags.push(tag.innerText.toLowerCase())
}
// Create sets from the arrays
const [set1, set2] = [new Set(wantedTags), new Set(tags)]
const common = [...set1].filter((x) => set2.has(x))
if (common.length === wantedTags.length) {
category.style.display = ''
} else if (wantedTags[0] === '') {
category.style.display = ''
} else {
category.style.display = 'none'
}
}
},
async getNextCategory() { async getNextCategory() {
let bottomOfWindow = let bottomOfWindow =
document.documentElement.scrollTop + window.innerHeight === document.documentElement.scrollTop + window.innerHeight ===
@ -130,27 +101,9 @@ export default {
<div class="p-2 text-contrast"> <div class="p-2 text-contrast">
<h1 class="font-bold text-5xl">{{ $t('home.discover') }}</h1> <h1 class="font-bold text-5xl">{{ $t('home.discover') }}</h1>
<p class="text-xl">{{ $t('home.discoverDescription') }}</p> <p class="text-xl">{{ $t('home.discoverDescription') }}</p>
<div class="pt-5 inline-flex">
<p class="mr-2 font-bold">{{ $t('home.tagDescription') }}</p>
<div class="relative">
<label for="searchBar" class="hidden">{{ $t('main.search') }}</label>
<v-icon name="io-search-outline" class="absolute my-auto inset-y-0 left-2"></v-icon>
<input
type="text"
id="searchBar"
name="searchBar"
:placeholder="$t('main.search')"
v-model="filterTags"
@keypress="filterSearches(filterTags)"
@keyup="filterSearches(filterTags)"
class="rounded-md p-1 pl-8 placeholder:text-white"
/>
</div>
</div>
</div> </div>
<ul ref="categoryList" class="flex flex-wrap justify-center"> <ul ref="categoryList" class="flex flex-wrap">
<li <li
v-for="category in data" v-for="category in data"
:key="category.name" :key="category.name"

View File

@ -12,6 +12,7 @@ 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 VueTitle from '@/components/VueTitle.vue' import VueTitle from '@/components/VueTitle.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 } from '@/mixins'
@ -25,6 +26,7 @@ export default {
const data = ref<StreamerData>() const data = ref<StreamerData>()
const status = ref<'ok' | 'error'>() const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl') const rootBackendUrl = inject('rootBackendUrl')
const isTheatreMode = ref(false)
const videoOptions = { const videoOptions = {
autoplay: getSetting('autoplay'), autoplay: getSetting('autoplay'),
controls: true, controls: true,
@ -43,7 +45,8 @@ export default {
status, status,
videoOptions, videoOptions,
audioOptions, audioOptions,
shareModalVisible: ref(false) shareModalVisible: ref(false),
isTheatreMode
} }
}, },
async mounted() { async mounted() {
@ -73,7 +76,8 @@ export default {
AudioPlayer, AudioPlayer,
AboutTab, AboutTab,
ShareModal, ShareModal,
VueTitle VueTitle,
ActionButtons
}, },
methods: { methods: {
truncate, truncate,
@ -81,6 +85,9 @@ export default {
getSetting, getSetting,
toggleShareModal() { toggleShareModal() {
this.shareModalVisible = !this.shareModalVisible this.shareModalVisible = !this.shareModalVisible
},
toggleTheatreMode() {
this.isTheatreMode = !this.isTheatreMode
} }
} }
} }
@ -102,7 +109,10 @@ export default {
> >
<vue-title :title="data.username"></vue-title> <vue-title :title="data.username"></vue-title>
<div <div
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="[
'content-container',
isTheatreMode ? 'content-container-theatre' : 'content-container-normal'
]"
> >
<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 v-if="Boolean($route.query['audio-only']) === false" :options="videoOptions">
@ -118,73 +128,67 @@ export default {
/> />
<div class="w-full flex-wrap md:p-3"> <div class="w-full flex-wrap md:p-3">
<div class="inline-flex md:w-4/5"> <div class="flex flex-col md:flex-row justify-between">
<div class="w-20 h-20 relative"> <div class="md:w-3/5">
<img <div class="inline-flex">
:src="data.pfp" <div class="w-20 h-20 relative">
class="rounded-full border-4 p-0.5 w-auto h-20" <img
:style="`border-color: ${data.colorHex};`" :src="data.pfp"
/> class="rounded-full border-4 p-0.5 w-auto h-20"
:style="`border-color: ${data.colorHex};`"
<span />
v-if="data.isLive" <span
class="absolute flex left-1/2 translate-x-[-50%] whitespace-nowrap uppercase top-16 bg-red font-bold text-sm p-1.5 py-0.5 rounded-md" v-if="data.isLive"
>{{ $t('main.live') }}</span class="absolute flex left-1/2 translate-x-[-50%] whitespace-nowrap uppercase top-16 bg-red font-bold text-sm p-1.5 py-0.5 rounded-md"
> >{{ $t('main.live') }}</span>
</div> </div>
<div class="ml-3 content-between">
<div class="ml-3 content-between"> <div>
<div class=""> <h1 class="text-2xl md:text-4xl font-bold inline-block">{{ data.username }}</h1>
<h1 class="text-2xl md:text-4xl font-bold inline-block">{{ data.username }}</h1> <a v-if="$route.query['audio-only'] !== 'true'" href="?audio-only=true">
<a v-if="$route.query['audio-only'] !== 'true'" href="?audio-only=true"> <v-icon name="bi-headphones" class="ml-1 w-8 h-8 inline-block"></v-icon>
<v-icon name="bi-headphones" class="ml-1 w-8 h-8 inline-block"></v-icon> </a>
</a> <a v-else :href="$route.params.username.toString()">
<a v-else :href="$route.params.username.toString()"> <v-icon name="bi-camera-video-fill" class="ml-1 w-8 h-8 inline-block"></v-icon>
<v-icon name="bi-camera-video-fill" class="ml-1 w-8 h-8 inline-block"></v-icon> </a>
</a> </div>
<div v-if="data.stream" class="w-full md:w-[17rem]">
<p class="text-sm font-bold text-neutral self-end">
{{ truncate(data.stream.title, 130) }}
</p>
</div>
</div>
</div> </div>
<div v-if="data.stream" class="w-[14rem] md:w-[17rem]"> </div>
<p class="text-sm font-bold text-neutral self-end"> <div class="md:w-2/5 mt-4 md:mt-0">
{{ truncate(data.stream.title, 130) }} <div v-if="!data.isLive" class="w-full">
<p class="font-bold bg-overlay0 p-3 py-2 rounded-lg w-min float-right border-2 border-red">
OFFLINE
</p> </p>
</div> </div>
<div v-else class="w-full">
<ul class="flex flex-wrap justify-end gap-2 text-xs font-bold" v-if="getSetting('streamTagsVisible')">
<li v-for="tag in data.stream!.tags" :key="tag" class="inline-flex bg-overlay0 p-1.5 px-2 rounded-md">
{{ tag }}
</li>
</ul>
</div>
</div> </div>
</div> </div>
<div class="flex justify-between items-center mt-4">
<div class="flex-col md:inline-flex md:w-1/5 float-right h-full text-right"> <div class="pt-2 inline-flex">
<div v-if="!data.isLive" class="w-full"> <follow-button :username="data.login"></follow-button>
<p <p class="align-baseline font-bold ml-3">
class="font-bold bg-overlay0 p-3 py-2 rounded-lg w-min float-right border-2 border-red" {{ abbreviate(data.followers) }} {{ $t('main.followers') }}
>
OFFLINE
</p> </p>
</div> </div>
<div v-else class="w-full"> <action-buttons
<ul :showDownload="false"
class="text-xs font-bold text-left md:text-right space-x-1 space-y-1 overflow-y-auto" :showTheatreMode="true"
v-if="getSetting('streamTagsVisible')" :showShare="true"
> @toggleTheatreMode="toggleTheatreMode"
<li @toggleShareModal="toggleShareModal"
v-for="tag in data.stream!.tags" />
:key="tag"
class="inline-flex bg-overlay0 p-1.5 px-2 rounded-md"
>
{{ tag }}
</li>
</ul>
</div>
</div>
<div class="pt-2 space-x-2 items-center inline-flex">
<follow-button :username="data.login"></follow-button>
<p class="align-baseline font-bold ml-3">
{{ abbreviate(data.followers) }} {{ $t('main.followers') }}
</p>
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple">
<v-icon name="fa-share-alt"></v-icon>
</button>
</div> </div>
</div> </div>
@ -203,3 +207,4 @@ export default {
></twitch-chat> ></twitch-chat>
</div> </div>
</template> </template>

View File

@ -9,11 +9,12 @@ import FollowButton from '@/components/FollowButton.vue'
import LoadingScreen from '@/components/LoadingScreen.vue' 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 VueTitle from '@/components/VueTitle.vue'
import ActionButtons from '@/components/ActionButtons.vue'
import type { Video } from '@/types' import type { Video } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
import { getSetting } from '@/settingsManager' import { getSetting } from '@/settingsManager'
import VueTitle from '@/components/VueTitle.vue'
interface ChatComponent { interface ChatComponent {
updateVodComments: (time: number) => void updateVodComments: (time: number) => void
@ -41,7 +42,8 @@ export default {
status: ref<'ok' | 'error'>(), status: ref<'ok' | 'error'>(),
videoOptions, videoOptions,
time: ref(0), time: ref(0),
shareModalVisible: ref(false) shareModalVisible: ref(false),
isTheatreMode: ref(false)
} }
}, },
async mounted() { async mounted() {
@ -63,7 +65,8 @@ export default {
LoadingScreen, LoadingScreen,
AboutTab, AboutTab,
ShareModal, ShareModal,
VueTitle VueTitle,
ActionButtons
}, },
methods: { methods: {
truncate, truncate,
@ -77,6 +80,9 @@ export default {
getSetting, getSetting,
toggleShareModal() { toggleShareModal() {
this.shareModalVisible = !this.shareModalVisible this.shareModalVisible = !this.shareModalVisible
},
toggleTheatreMode() {
this.isTheatreMode = !this.isTheatreMode
} }
} }
} }
@ -98,7 +104,10 @@ export default {
> >
<vue-title :title="'VOD - ' + data.title"></vue-title> <vue-title :title="'VOD - ' + data.title"></vue-title>
<div <div
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="[
'content-container',
isTheatreMode ? 'content-container-theatre' : 'content-container-normal'
]"
> >
<div class="w-full mx-auto rounded-lg mb-5"> <div class="w-full mx-auto rounded-lg mb-5">
<video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate"> <video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate">
@ -133,9 +142,13 @@ export default {
</p> </p>
</div> </div>
<button @click="toggleShareModal" class="px-2 py-1.5 rounded-lg bg-purple"> <action-buttons
<v-icon name="fa-share-alt"></v-icon> :showDownload="false"
</button> :showTheatreMode="true"
:showShare="true"
@toggleTheatreMode="toggleTheatreMode"
@toggleShareModal="toggleShareModal"
/>
</div> </div>
</div> </div>