use App\Models\TwitchBotCommand;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Gate;
class ChannelController extends Controller {
->orWhere('short_name', 'LIKE', '%'.$validatedData['phrase'].'%');
}
$channels = $channels->limit(5);
- return $channels->get()->toJson();
+ return $this->sendChannels($channels->get());
}
public function single(Request $request, Channel $channel) {
$this->authorize('view', $channel);
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function chat(Request $request, Channel $channel) {
$this->authorize('editRestream', $channel);
$nick = empty($validatedData['bot_nick']) ? 'localhorsttv' : $validatedData['bot_nick'];
TwitchBotCommand::chat($channel->twitch_chat, $validatedData['text'], $request->user(), $nick);
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function chatSettings(Request $request, Channel $channel) {
$this->authorize('editRestream', $channel);
$channel->chat_settings = $validatedData;
$channel->save();
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function join(Request $request, Channel $channel) {
}
$channel->save();
TwitchBotCommand::join($channel->twitch_chat, $request->user(), $nick);
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function part(Request $request, Channel $channel) {
}
$channel->save();
TwitchBotCommand::part($channel->twitch_chat, $request->user(), $nick);
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function deleteCommand(Channel $channel, $command) {
$channel->chat_commands = $cmds;
$channel->save();
}
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function saveCommand(Request $request, Channel $channel, $command) {
$channel->chat_commands = $cmds;
$channel->save();
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function controlGuessingGame(Request $request, Channel $channel, $name) {
break;
}
- return $channel->toJson();
+ return $this->sendChannel($channel);
}
public function getGuessingGame(Channel $channel, $name) {
];
}
+ public function getGuessingGameMonitor($key) {
+ $channel = Channel::where('access_key', '=', $key)->firstOrFail();
+
+ $cutoff = $channel->guessing_start;
+ if (is_null($cutoff)) {
+ $last = $channel->winners()->latest()->first();
+ $cutoff = $last->pod;
+ }
+ $guesses = $channel->guesses()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+ $winners = $channel->winners()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+ return [
+ 'channel' => $channel->toArray(),
+ 'guesses' => $guesses->toArray(),
+ 'winners' => $winners->toArray(),
+ ];
+ }
+
public function saveGuessingGame(Request $request, Channel $channel, $name) {
$this->authorize('editRestream', $channel);
$channel->guessing_settings = $settings;
$channel->save();
+ return $this->sendChannel($channel);
+ }
+
+ protected function sendChannel(Channel $channel) {
+ if (Gate::allows('editRestream', $channel)) {
+ $this->unhideChannel($channel);
+ }
return $channel->toJson();
}
+ protected function sendChannels(Collection $channels) {
+ foreach ($channels as $channel) {
+ if (Gate::allows('editRestream', $channel)) {
+ $this->unhideChannel($channel);
+ }
+ }
+ return $channels->toJson();
+ }
+
+ private function unhideChannel(Channel $channel) {
+ $channel->makeVisible([
+ 'access_key',
+ 'chat',
+ 'chat_commands',
+ 'chat_settings',
+ 'guessing_settings',
+ 'join',
+ 'twitch_chat',
+ ]);
+ }
+
}
namespace App\Models;
+use Illuminate\Broadcasting\Channel as PublicChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
$channels = [
new PrivateChannel('Channel.'.$this->id),
];
+ if (!empty($this->access_key)) {
+ $channels[] = new PublicChannel('ChannelKey.'.$this->access_key);
+ }
return $channels;
}
'chat' => 'boolean',
'chat_commands' => 'array',
'chat_settings' => 'array',
+ 'guessing_end' => 'datetime',
'guessing_settings' => 'array',
'guessing_start' => 'datetime',
- 'guessing_end' => 'datetime',
'languages' => 'array',
'join' => 'boolean',
];
protected $hidden = [
+ 'access_key',
+ 'chat',
+ 'chat_commands',
+ 'chat_settings',
'created_at',
'ext_id',
+ 'guessing_settings',
+ 'join',
+ 'twitch_chat',
'updated_at',
];
namespace App\Models;
+use Illuminate\Broadcasting\Channel as PublicChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
$channels = [
new PrivateChannel('Channel.'.$this->channel_id),
];
+ if (!empty($this->channel->access_key)) {
+ $channels[] = new PublicChannel('ChannelKey.'.$this->channel->access_key);
+ }
return $channels;
}
namespace App\Models;
+use Illuminate\Broadcasting\Channel as PublicChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Factories\HasFactory;
$channels = [
new PrivateChannel('Channel.'.$this->channel_id),
];
+ if (!empty($this->channel->access_key)) {
+ $channels[] = new PublicChannel('ChannelKey.'.$this->channel->access_key);
+ }
return $channels;
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('channels', function (Blueprint $table) {
+ $table->string('access_key')->default('');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('channels', function (Blueprint $table) {
+ $table->dropColumn('access_key');
+ });
+ }
+};
'../pages/GuessingGameControls'
)}
/>
+ <Route
+ path="monitor/:key"
+ lazy={() => import(
+ /* webpackChunkName: "guessing" */
+ '../pages/GuessingGameMonitor'
+ )}
+ />
</Route>
</Route>
)
Icon.ALLOWED = makePreset('AllowedIcon', 'square-check');
Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket');
Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running');
+Icon.BROWSER_SOURCE = makePreset('BrowserSourceIcon', 'tv');
Icon.CHART = makePreset('ChartIcon', 'chart-line');
Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
Icon.UNKNOWN = makePreset('UnknownIcon', 'square-question');
Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open');
Icon.VIDEO = makePreset('VideoIcon', 'video');
+Icon.WARNING = makePreset('WarningIcon', 'triangle-exclamation');
Icon.VOLUME = makePreset('VolumeIcon', 'volume-high');
Icon.YOUTUBE = makePreset('YoutubeIcon', ['fab', 'youtube']);
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const Slider = ({ children, duration, vertical }) => {
+ const [index, setIndex] = React.useState(0);
+
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ setIndex(i => (i + 1) % React.Children.count(children));
+ }, duration);
+ return () => {
+ clearInterval(interval);
+ };
+ }, [React.Children.count(children), duration]);
+
+ return <div className={`slider-container ${vertical ? 'vertical' : 'horizontal'}`}>
+ <div className="slider-slides" style={{
+ transform: vertical ? `translateY(${-index * 100}%)` : `translateX(${-index * 100}%)`
+ }}>
+ {children}
+ </div>
+ </div>;
+};
+
+Slider.propTypes = {
+ children: PropTypes.node,
+ duration: PropTypes.number,
+ vertical: PropTypes.bool,
+};
+
+Slider.defaultProps = {
+ duration: 2500,
+};
+
+const Slide = ({ children }) => {
+ return <div className="slider-slide">
+ {children}
+ </div>;
+};
+
+Slide.propTypes = {
+ children: PropTypes.node,
+};
+
+Slider.Slide = Slide;
+
+export default Slider;
<Col className="mt-5" md={12}>
<div className="d-flex align-items-end justify-content-between">
<h3>{t('twitchBot.guessingGame.settings')}</h3>
- <Button
- onClick={() => {
- window.open(
- '/guessing-game/controls',
- '',
- 'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
- );
- }}
- title={t('twitchBot.guessingGame.popoutControls')}
- variant="outline-secondary"
- >
- <Icon.OPEN title="" />
- </Button>
+ <div className="button-bar">
+ {channel.access_key ?
+ <Button
+ onClick={() => {
+ window.open(
+ `/guessing-game/monitor/${channel.access_key}`,
+ );
+ }}
+ title={t('button.browserSource')}
+ variant="outline-secondary"
+ >
+ <Icon.BROWSER_SOURCE title="" />
+ </Button>
+ : null}
+ <Button
+ onClick={() => {
+ window.open(
+ '/guessing-game/controls',
+ '',
+ 'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
+ );
+ }}
+ title={t('twitchBot.guessingGame.popoutControls')}
+ variant="outline-secondary"
+ >
+ <Icon.OPEN title="" />
+ </Button>
+ </div>
</div>
<GuessingSettingsForm
name="gtbk"
button: {
add: 'Hinzufügen',
back: 'Zurück',
+ browserSource: 'Browser Source',
cancel: 'Abbrechen',
chart: 'Diagramm',
close: 'Schließen',
button: {
add: 'Add',
back: 'Back',
+ browserSource: 'Browser source',
cancel: 'Cancel',
chart: 'Chart',
close: 'Close',
--- /dev/null
+import axios from 'axios';
+import moment from 'moment';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+import {
+ hasActiveGuessing,
+ isAcceptingGuesses,
+ patchGuess,
+ patchWinner,
+} from '../helpers/Channel';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Icon from '../components/common/Icon';
+import Slider from '../components/common/Slider';
+
+export const Component = () => {
+ const [channel, setChannel] = React.useState({});
+ const [guesses, setGuesses] = React.useState([]);
+ const [winnerExpiry, setWinnerExpiry] = React.useState(moment().subtract(15, 'second'));
+ const [winners, setWinners] = React.useState([]);
+
+ const params = useParams();
+ const { key } = params;
+
+ React.useEffect(() => {
+ if (!key) return;
+ axios.get(`/api/guessing-game-monitor/${key}`)
+ .then(res => {
+ setChannel(res.data.channel);
+ res.data.guesses.forEach(g => {
+ setGuesses(gs => patchGuess(gs, g));
+ });
+ res.data.winners.forEach(w => {
+ setWinners(ws => patchGuess(ws, w));
+ });
+ });
+ window.Echo.channel(`ChannelKey.${key}`)
+ .listen('.GuessingGuessCreated', (e) => {
+ setGuesses(gs => patchGuess(gs, e.model));
+ })
+ .listen('.GuessingWinnerCreated', (e) => {
+ setWinners(ws => patchWinner(ws, e.model));
+ })
+ .listen('.ChannelUpdated', (e) => {
+ setChannel(c => ({ ...c, ...e.model }));
+ });
+ return () => {
+ window.Echo.leave(`ChannelKey.${key}`);
+ };
+ }, [key]);
+
+ React.useEffect(() => {
+ if (isAcceptingGuesses(channel)) {
+ setGuesses(gs => gs.filter(g => g.created_at >= channel.guessing_start));
+ setWinners([]);
+ }
+ }, [channel]);
+
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ setWinnerExpiry(moment().subtract(15, 'second'));
+ }, 1000);
+ return () => {
+ clearInterval(interval);
+ };
+ }, []);
+
+ const guessingStats = React.useMemo(() => {
+ const stats = {
+ counts: [],
+ lastWin: null,
+ max: 0,
+ wins: [],
+ winners: [],
+ };
+ for (let i = 0; i < 22; ++i) {
+ stats.counts.push(0);
+ stats.wins.push(false);
+ }
+ const seen = [];
+ guesses.forEach(guess => {
+ if (seen[guess.uid]) {
+ --stats.counts[parseInt(seen[guess.uid].guess, 10) - 1];
+ }
+ ++stats.counts[parseInt(guess.guess, 10) - 1];
+ seen[guess.uid] = guess;
+ });
+ winners.forEach(winner => {
+ if (winner.score) {
+ stats.wins[parseInt(winner.guess, 10) - 1] = true;
+ stats.winners.push(winner.uname);
+ }
+ if (!stats.lastWin || stats.lastWin < winner.created_at) {
+ stats.lastWin = winner.created_at;
+ }
+ });
+ for (let i = 0; i < 22; ++i) {
+ if (stats.counts[i] > stats.max) {
+ stats.max = stats.counts[i];
+ }
+ }
+ return stats;
+ }, [guesses, winners]);
+
+ const getNumberHeight = React.useCallback((number) => {
+ if (!guessingStats || !guessingStats.max) return 3;
+ if (!number) return 3;
+ return Math.max(0.05, number / guessingStats.max) * 100;
+ }, [guessingStats]);
+
+ const getStatClass = React.useCallback((index) => {
+ const names = ['guessing-stat'];
+ if (guessingStats.wins[index]) {
+ names.push('has-won');
+ }
+ return names.join(' ');
+ }, [guessingStats]);
+
+ const showOpen = React.useMemo(() => {
+ return isAcceptingGuesses(channel);
+ }, [channel]);
+
+ const showClosed = React.useMemo(() => {
+ return hasActiveGuessing(channel) && !isAcceptingGuesses(channel);
+ }, [channel]);
+
+ const showWinners = React.useMemo(() => {
+ return !hasActiveGuessing(channel) && (
+ guessingStats?.lastWin &&
+ moment(guessingStats.lastWin).isAfter(winnerExpiry));
+ }, [channel, guessingStats, winnerExpiry]);
+
+ return <ErrorBoundary>
+ <Helmet>
+ <title>Guessing Game</title>
+ </Helmet>
+ <Container className="guessing-game-monitor" fluid>
+ {showOpen || showClosed || showWinners ?
+ <div className="message-box">
+ {showOpen ?
+ <div className="message-title accepting-guesses">
+ <Icon.WARNING className="message-icon" />
+ <div className="message-text">
+ <Slider duration={3500}>
+ <Slider.Slide>GT Big Key Guessing Game</Slider.Slide>
+ <Slider.Slide>Zahlen von 1 bis 22 in den Chat!</Slider.Slide>
+ </Slider>
+ </div>
+ <Icon.WARNING className="message-icon" />
+ </div>
+ : null}
+ {showClosed ?
+ <div className="message-title guessing-closed">
+ <div className="message-text">
+ Anmeldung geschlossen
+ </div>
+ </div>
+ : null}
+ {showWinners ?
+ <div className="message-title guessing-winners">
+ <div className="message-text">
+ {guessingStats.winners.length ?
+ <Slider duration={2500}>
+ <Slider.Slide>Herzlichen Glückwunsch!</Slider.Slide>
+ {guessingStats.winners.map(winner =>
+ <Slider.Slide key={winner}>{winner}</Slider.Slide>
+ )}
+ </Slider>
+ :
+ 'Leider keiner richtig'
+ }
+ </div>
+ </div>
+ : null}
+ <div className="guessing-stats">
+ {guessingStats.counts.map((number, index) =>
+ <div className={getStatClass(index)} key={index}>
+ <div className="guessing-box">
+ <div
+ className="guessing-box-bar"
+ style={{ height: `${getNumberHeight(number)}%` }}
+ />
+ </div>
+ <div className="guessing-number">{index + 1}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ : null}
+ </Container>
+ </ErrorBoundary>;
+};
opacity: .6;
background: repeating-linear-gradient(150deg, #444, #444 1ex, transparent 1ex, transparent 1em);
}
+
+.guessing-game-monitor {
+ font-size: 5vw;
+ text-align: center;
+
+ .message-box {
+ display: inline-block;
+ padding: 0.5ex 1ex;
+ border-radius: 0.5ex;
+ background: rgba(0, 0, 0, 0.5);
+ }
+ .message-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .accepting-guesses .message-icon {
+ color: $warning;
+ }
+ .guessing-closed .message-icon {
+ color: $danger;
+ }
+ .message-text {
+ flex-grow: 1;
+ margin-left: 1ex;
+ margin-right: 1ex;
+ width: 70vw;
+ height: 1.2em;
+ line-height: 1;
+ }
+
+ .guessing-stats {
+ display: flex;
+ font-size: 3vw;
+ justify-content: center;
+ margin-top: 1ex;
+ }
+ .guessing-stat {
+ width: 1.3em;
+ }
+ .guessing-box {
+ width: 100%;
+ height: 2em;
+ position: relative;
+ }
+ .guessing-box-bar {
+ background-color: $primary;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ }
+ .has-won .guessing-box-bar {
+ background-color: $danger;
+ }
+}
}
}
+.slider-container {
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+
+ > .slider-slides {
+ transition: transform 600ms;
+ }
+ &.horizontal {
+ > .slider-slides {
+ height: 100%;
+ white-space: nowrap;
+ > .slider-slide {
+ display: inline-block;
+ width: 100%;
+ }
+ }
+ }
+ &.vertical {
+ > .slider-slides {
+ width: 100%;
+ }
+ }
+}
+
.snes-button-a,
.snes-button-b,
.snes-button-x,
Route::post('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@controlGuessingGame');
Route::put('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@saveGuessingGame');
+Route::get('guessing-game-monitor/{key}', 'App\Http\Controllers\ChannelController@getGuessingGameMonitor');
+
Route::get('content', 'App\Http\Controllers\TechniqueController@search');
Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single');
Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update');