1 Commits
Author SHA1 Message Date
unicxrn c7c9cc294c Twispero 1.1.0 2026-10-19 20:45:41 +02:00
64 changed files with 2230 additions and 200 deletions

No files matched your search

+20
View File
@@ -15,3 +15,23 @@ compile_commands.json
.DS_Store
Thumbs.db
desktop.ini
# Assistant and agent files. None of them belong in the source.
CLAUDE.md
AGENTS.md
GEMINI.md
.claude/
.cursor/
.cursorrules
.cursorignore
.mcp.json
.serena/
.gemini/
.codex/
.continue/
.aider*
.windsurf*
.github/copilot-instructions.md
.superpowers/
docs/superpowers/
graphify-out/
+18
View File
@@ -2,6 +2,24 @@
What changed in each release of Twispero. The newest is at the top.
## 1.1.0
A live picture on Home, a stream language filter and chat replies, plus fixes for a crash when closing from the PS button menu and for sound and picture going out of sync.
### Added
- Home's top stream plays live in its picture, without sound. Press X to watch it. It stops after 90 seconds without a button press, and Settings > Twitch can turn it off.
- Pick a stream language with L1 and R1 on Browse or on a category's Live tab. Home's Popular now row and every category's live streams follow it.
- Reply to a chat message. L1 pauses chat on the newest message, Up and Down pick another one, and L1 again opens the keyboard. This needs a Twitch login.
- Audio only for live streams in the quality menu (R2), as videos already had
### Fixed
- Closing the app from the PS button menu while something played could crash the console. The app now stops drawing the moment the PS5 takes over, which happens before the close goes through.
- Audio and video drifting apart after the stream buffers on a slow connection (#1). Thanks to jumboz95 for reporting it.
- A blank line on Home, channel pages and the player when a title is all Japanese, Korean or Chinese. The app still can't show those characters, so it skips the line now.
- Twitch's "processing" picture on a past broadcast that's still being recorded. It shows the live picture now.
## 1.0.0
The first release. Please report anything that breaks, and say which firmware you're on.
+7 -4
View File
@@ -22,8 +22,9 @@
## What it does
- Live streams, past broadcasts, highlights, uploads and clips, decoded by the PS5's own hardware.
- Chat next to the video, over it, or turned off, with emotes, badges and name colours. 7TV, BetterTTV and FrankerFaceZ emotes show up too.
- Home, Browse, categories, Search (it remembers your last 8 searches) and channel pages with the schedule, videos and clips.
- Chat next to the video, over it, or turned off, with emotes, badges and name colours. 7TV, BetterTTV and FrankerFaceZ emotes show up too. Logged in, you can reply to a message.
- Home, Browse, categories, Search (it remembers your last 8 searches) and channel pages with the schedule, videos and clips. Home's top stream plays live, without sound, at the top of the page.
- A stream language filter for Home's popular streams and each category's live streams.
- Any quality the stream offers, from source down to audio only.
- A timeline for videos and clips, and videos pick up where you left off.
- Log in with a QR code to see the channels you follow, get a note when one goes live, chat, and make clips.
@@ -47,7 +48,7 @@ To update, close the app and replace the folder with the new one. Your login and
### Closing the app
Press Left at the start of a row to open the side menu, pick Quit and wait for the "safe to close" screen. Then close the app from the PS button menu. Closing it with the PS button while a stream plays can crash the console.
Close it from the PS button menu like any game. The app stops drawing as soon as the PS5 takes over, before the close goes through. In 1.0, closing it like that while a stream played could crash the console; 1.1 fixed that. The side menu (press Left at the start of a row) also has Quit, which stops everything and shows a "safe to close" screen first.
## Requirements
@@ -81,6 +82,7 @@ In the menus:
| Circle | Back |
| Square | Open the channel of the stream or clip you're on |
| Triangle | Reload |
| L1 and R1 | On Browse and a category's Live tab: pick the stream language |
| Left, at the start of a row | Open the side menu: Home, Following, Browse, Search, Settings and Quit |
In the player:
@@ -91,11 +93,12 @@ In the player:
| R1 | Chat next to the video, over it, or off |
| R2 | Quality |
| Triangle | Send a chat message (logged in) |
| L1, then Up and Down | Pause chat and pick a message, then L1 again to reply to it (logged in) |
| Square | Clip the last moments of a live stream, then scan the QR code to see it (logged in) |
| Left and Right | Skip 10 seconds in videos and clips; hold to scrub |
| Circle | Back, then Stop to leave the player |
Settings has three pages. Twitch has your account, the default quality, where chat goes, the chat text size, third-party emotes and live notifications. Controls & sound has the keyboard (the PS5's own or the app's), navigation sounds and the controller light. System has Clear history, About and Quit.
Settings has three pages. Twitch has your account, the default quality, where chat goes, the chat text size, third-party emotes, live notifications and the live preview on Home. Controls & sound has the keyboard (the PS5's own or the app's), navigation sounds and the controller light. System has Clear history, About and Quit.
## What it can't do
+8
View File
@@ -104,7 +104,9 @@ MEDIA_SRCS := \
media/src/evo_packet_queue.c \
media/src/evo_audio_resample.c \
media/src/evo_audio_out.c \
media/src/evo_av_clock.c \
media/src/evo_hw.c \
media/src/evo_sysui.c \
media/src/evo_perf_monitor.c \
media/src/evo_demux.c \
media/src/evo_vdec_ffmpeg.c \
@@ -255,7 +257,13 @@ twitch/obj/mod_%.o: twitch/src/%.cpp
core/src/Application.o: CXXFLAGS += -Itwitch/include
core/src/screens/TwitchHomeScreen.o: CXXFLAGS += -Itwitch/include
core/src/screens/TwitchBrowseScreen.o: CXXFLAGS += -Itwitch/include
# Browse saves the language filter through evo_twitch_settings.h, whose
# AppSettings is the core's (twitch/settings.hpp). The language list itself
# stays in the module, so this screen does not include the JSON library.
core/src/screens/TwitchBrowseScreen.o: CXXFLAGS += -I$(TWITCH_CORE_DIR)/include
core/src/screens/TwitchCategoryScreen.o: CXXFLAGS += -Itwitch/include
# A category's live list is in that language too, and it steps it with L1/R1.
core/src/screens/TwitchCategoryScreen.o: CXXFLAGS += -I$(TWITCH_CORE_DIR)/include
core/src/screens/TwitchSearchScreen.o: CXXFLAGS += -Itwitch/include
core/src/screens/TwitchChannelScreen.o: CXXFLAGS += -Itwitch/include
# The quality picker lists the launcher's qualities and asks it to switch.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
@@ -73,6 +73,9 @@ struct PlaybackSource {
* of a stream that is still on) plays from its first segment, not from
* near its live edge. */
bool from_start = false;
/** Nobody asked for it (Home's live picture): a start that fails is
* logged, not toasted. */
bool quiet = false;
/** A non-local source: a network stream, not a file on the console. */
bool isProvider() const { return !provider.empty(); }
@@ -5,7 +5,9 @@
#include "evo/fsm/StateMachine.hpp"
#include "evo_hui_twitch.h"
#include "evo_rmlui_bridge.h"
#include "twitch/chat_log.hpp"
#include <deque>
#include <string>
#include <vector>
@@ -77,13 +79,39 @@ private:
/* Writes an R1 change that is waiting (m_chatSavePending). */
void flushChatSave();
/* Triangle on a Twitch stream: the keyboard for a chat message, or a
* toast saying why not. */
void openChatKeyboard(IPlaybackController* playback);
* toast saying why not. replyId, when set, sends it as a reply to that
* line and titles the keyboard for replyName. */
void openChatKeyboard(IPlaybackController* playback, const std::string& replyId = {},
const std::string& replyName = {});
/* Up/Down walk the chat lines that can be replied to (dir -1 is older);
* the first Up pauses chat, Down past where it paused lets it go on. */
void moveChatReply(int dir);
/* L1: with nothing picked, pauses chat on its newest line; with a line
* picked, the keyboard to reply to it. */
void replyToChat(IPlaybackController* playback);
/* Keeps a copy of the lines on screen now (m_pausedLines) and says so. */
void pauseChat(const std::deque<twitch::ChatLine>& live);
/* Pauses chat and picks its newest line that can be replied to. False,
* and chat runs on, when there is none. */
bool startChatPick();
/* Marks `line` (in m_pausedLines) as the one to reply to. */
void pickChatLine(const twitch::ChatLine& line);
/* No line picked: chat goes on from its newest line. */
void resumeChat();
/* Chat moved to another channel: it goes on. */
void checkReplyChannel();
/* Every frame: chat goes on once the channel changed, or nothing was
* pressed for kChatReplyIdleMs. */
void tickChatReply();
/* One past the last line to draw of `lines`: all of them, or while a
* line is picked, up to kChatReplyBelow under it. */
std::size_t chatWindowEnd(const std::deque<twitch::ChatLine>& lines) const;
/* The keyboard's confirm: hands the message to the chat service.
* evo_keyboard calls it on the main thread. */
static void OnChatSubmitted(const char* text, void* userdata) noexcept;
/* Brings m_chatUrls in line with the newest lines of the chat log. */
void syncChatUrls(bool badgesKnown);
/* Brings m_chatUrls in line with `lines` before `end` (the log's, or the
* paused copy). */
void syncChatUrls(bool badgesKnown, const std::deque<twitch::ChatLine>& lines, std::size_t end);
/* Square on a live Twitch stream: asks the clip maker for a clip
* (evo_twitch_clip.h) and toasts "Making a clip...", or why not. */
void makeClip(IPlaybackController* playback);
@@ -121,6 +149,7 @@ private:
unsigned long long m_chatRevision = 0;
bool m_chatBadgesKnown = false;
bool m_chatSynced = false;
unsigned long long m_chatEndSerial = 0; /* the last line synced (chatWindowEnd) */
std::vector<evo_hui_twitch_chat_line_t> m_chatLines;
std::vector<evo_hui_twitch_chat_segment_t> m_chatSegments;
std::vector<evo_hui_twitch_image_t> m_chatBadgePics;
@@ -132,6 +161,11 @@ private:
bool m_chatSavePending = false; /* R1 changed the mode; the file isn't written yet */
uint64_t m_chatSaveDueMs = 0; /* written then, unless R1 comes again */
std::string m_chatSendChannel; /* the login the open keyboard writes to */
std::string m_chatReplyId; /* set while the open keyboard is a reply */
std::string m_replyChannel; /* chat channel the highlight belongs to */
unsigned long long m_replySerial = 0; /* the line picked to reply to, 0 for none: chat runs */
std::deque<twitch::ChatLine> m_pausedLines; /* the lines on screen when chat paused */
uint64_t m_replyUsedMs = 0; /* the last Up, Down or L1 on it */
bool m_keyboardOverVideo = false; /* EVO's own keyboard was up last frame drawn */
/* The card for a clip just made (Square): its page as a QR, shown for
@@ -13,7 +13,10 @@ namespace evo {
*
* The D-pad moves the cursor on the grid (Left at the start of a row opens the
* rail), Cross opens the category (TwitchCategoryScreen), Triangle reloads the
* list, Circle goes back (normally to Home).
* list, L1 and R1 change the stream language (twitch_screen::LanguageStepper:
* Home's popular streams and every category's live list; the categories
* themselves are the top ones in every language), Circle goes back
* (normally to Home).
*/
class TwitchBrowseScreen : public StatefulScreen {
public:
@@ -22,6 +25,8 @@ public:
ScreenId getScreenId() const override { return ScreenId::TwitchBrowse; }
void onEnter() override;
void onExit() override;
void update(double deltaMs) override;
bool handleInput(uint32_t pressed, uint32_t held, uint32_t released) override;
void render(uint32_t* framebuffer, int width, int height) override;
@@ -34,6 +39,8 @@ private:
void activate();
int m_cursor = 0;
std::string m_languageLine; /* the subtitle, kept for the kit's pointer */
twitch_screen::LanguageStepper m_language;
twitch_screen::RefreshToast m_refreshToast;
twitch_screen::PictureKeeper m_pictures;
};
@@ -24,8 +24,9 @@ namespace evo {
* (Left at the start of a row opens the rail); Up from the first row goes to
* the clips' chips and the tabs, where moving onto a tab opens it and Cross
* on a chip shows its clips. Cross plays the stream or the clip, Square opens
* its channel page, Triangle reloads the open tab's list, Circle goes back
* to where it was opened from (Browse, Home or Search).
* its channel page, Triangle reloads the open tab's list, L1 and R1 on the
* Live tab step the stream language (twitch_screen::LanguageStepper), Circle
* goes back to where it was opened from (Browse, Home or Search).
*/
class TwitchCategoryScreen : public StatefulScreen {
public:
@@ -42,6 +43,8 @@ public:
ScreenId getScreenId() const override { return ScreenId::TwitchCategory; }
void onEnter() override;
void onExit() override;
void update(double deltaMs) override;
bool handleInput(uint32_t pressed, uint32_t held, uint32_t released) override;
void render(uint32_t* framebuffer, int width, int height) override;
@@ -56,8 +59,10 @@ private:
/* Asks for the streams when the list is for another category, missing,
* failed or older than a minute; with force, always. This screen is the
* only one that asks for category streams, so m_requested says what the
* slot holds. */
* slot holds. A list in another stream language is another list. */
void refresh(bool force);
/* The category and the stream language, as m_requested holds them. */
std::string liveKey() const;
/* The same for the clips (another chip counts as another list). */
void refreshClips(bool force);
std::string clipsKey() const;
@@ -77,7 +82,9 @@ private:
std::string m_name;
std::string m_boxArtUrl;
int m_viewers = -1;
std::string m_requested; /* the name the slot was last asked for */
std::string m_requested; /* liveKey() when the slot was last asked for */
twitch_screen::LanguageStepper m_language;
std::string m_languageLine; /* "German streams", kept for the kit's pointer */
int m_cursor = 0;
int m_tab = LiveTab;
int m_focus = Grid;
@@ -25,6 +25,9 @@ namespace evo {
* any; Cross picks one up where it was left. Owns the cursor and asks the
* data service (twitch/include/evo_twitch_data.h) for the lists; the kit
* screen (ui_kit/src/evo_hui_twitch_home.cpp) draws what render() hands it.
* The hero plays its stream live and muted in its picture (syncPreview(),
* Settings > Twitch > Live preview on Home); Cross on the hero opens it.
* "Popular now" is in the language picked on Browse, and says which.
*
* Up/Down move between the hero and the rows, Left/Right within a row (Left
* at the start of a row opens the rail), Cross plays a stream or a video or
@@ -38,6 +41,7 @@ public:
ScreenId getScreenId() const override { return ScreenId::TwitchHome; }
void onEnter() override;
void onExit() override;
void update(double deltaMs) override;
bool handleInput(uint32_t pressed, uint32_t held, uint32_t released) override;
void render(uint32_t* framebuffer, int width, int height) override;
@@ -49,6 +53,15 @@ private:
/* Notices a login, a logout or another user: a new user's followed live
* channels are asked for. */
void followLogin();
/* The hero's muted live picture, while this screen is up: from
* kPreviewSettleMs after it opens until the pad has been left alone for
* kPreviewIdleMs (evo_twitch_preview()). */
void syncPreview();
static constexpr unsigned long long kPreviewSettleMs = 1000;
static constexpr unsigned long long kPreviewIdleMs = 90000;
unsigned long long m_enteredMs = 0;
unsigned long long m_lastInputMs = 0;
std::string m_streamsLanguage; /* Popular now's, kept for the kit's pointer */
/* Asks for the lists that are missing, failed or older than a minute;
* with force, for all of them. */
void refresh(bool force);
@@ -324,6 +324,38 @@ private:
unsigned long long m_lastMs = 0;
};
/* L1/R1's stream language, on Browse and on a category's Live tab. Home's
* "Popular now" (and its hero) and every category's live list are in it.
* A press steps it and says it in a toast at once; Settings take it, the
* file is written and Home's list is asked for again only once the presses
* have stopped for kDelayMs, or when the screen is left (apply(true)), so
* running through the list costs one request, not one a press. */
class LanguageStepper {
public:
static constexpr unsigned long long kDelayMs = 700;
/* L1 (next false) or R1 (next true). */
void step(bool next);
/* What to show now: the language stepped to, or the one in Settings. */
std::string shown() const;
/* Every frame; force on leaving. True on the frame Settings took a new
* language. */
bool apply(bool force);
private:
std::string m_code;
bool m_pending = false;
unsigned long long m_dueMs = 0;
};
/* "German streams", or "" for every language: the note beside a list that
* a language narrows. */
std::string languageNote(const std::string& code);
/* The two above live in TwitchBrowseScreen.cpp, which is built with the
* core's headers (evo_twitch_settings.h names its AppSettings); this header
* stays clear of them. */
/* A refresh that failed while the old list stays on screen: one toast with
* the reason (evo_twitch_data.h clears `error` when a refresh starts, so each
* failure is a change). A failure that leaves nothing to show is the
+103 -9
View File
@@ -60,6 +60,7 @@ extern "C" int perf_render_fps;
#include "evo_playback.h"
#include "evo_adec.h"
#include "evo_hw.h"
#include "evo_sysui.h"
#include "evo_rmlui_bridge.h"
#include "evo_perf_monitor.h"
#include "evo_vdec.h"
@@ -287,6 +288,9 @@ bool Application::initialize(int argc, char** argv) {
/* PS5 Pro detection, cached. The debug overlay names the console from
* it. Runs before the unjail, like the decoder probes. */
evo_hw_probe();
/* The system's focus flag, for closes from the PS menu (evo_sysui.h).
* Opened here, before the unjail, like the above. */
evo_sysui_probe();
if (!initHardware()) {
return false;
@@ -931,7 +935,9 @@ int Application::run() {
* restarting the channel keeps its chat meanwhile; any other
* start has nothing Twitch playing yet. */
const char* channel = evo_twitch_launcher_switching_channel();
if (!channel) {
/* Home's muted picture has no chat, also on the frame its start
* is back but not yet taken in by the launcher's pump. */
if (!channel && !evo_twitch_preview_active() && !evo_twitch_preview_starting()) {
const PlaybackSource* src = (m_playbackController && !evo_twitch_launcher_busy())
? m_playbackController->currentSource()
: nullptr;
@@ -987,6 +993,44 @@ int Application::run() {
}
g_pace.mark(0); /* pumps: net, usb remote, Twitch, jailbreak */
/*
* The system has the focus (its home screen, or a close from its
* menu on the way): no input and no GPU work until the app has it
* back (evo_sysui.h). On the way in the GPU is drained, so a close
* meets an idle GPU, as after the soft close. Seen on hardware
* (2026-10-19): the system takes the focus 1-2 s before it kills an
* app, and this notices within a frame. Home's picture stops, as
* nobody can see it; the pumps above keep running, and a stream in
* the player plays on with its sound. Not while the PS5's own
* keyboard is up: it is the system's, and it may hold the focus
* while the app has to keep reading it.
*/
{
static bool s_sysui_parked = false;
const bool lost = evo_sysui_focus_lost() == 1 && !evo_keyboard_is_native_active();
if (lost && !m_softClose) {
if (!s_sysui_parked) {
s_sysui_parked = true;
/* The GPU first: the kill can come well under a second
* after the focus moves (seen: about half a second). */
evo_agc_runtime_wait_idle(500);
evo_twitch_preview_stop();
evo_boot_log("sysui: focus left, drawing stopped and the GPU drained (screen %d)",
static_cast<int>(m_screenManager->getCurrentScreenId()));
evo_boot_log_flush();
}
lastButtons = 0;
usleep(10000);
continue;
}
if (s_sysui_parked) {
s_sysui_parked = false;
jb_repaint = 8; /* a few frames drawn whatever else is going on */
evo_boot_log("sysui: focus back, drawing again");
evo_boot_log_flush();
}
}
// 1. Controller input & auto-repeat
std::memset(&padData, 0, sizeof(padData));
uint32_t pressed = 0;
@@ -1155,8 +1199,10 @@ int Application::run() {
}
} else {
/* Not while a Twitch stream is starting: no screen may start
* or stop playback under its worker (evo_twitch.h). */
if (hasInput && !evo_twitch_launcher_busy()) {
* or stop playback under its worker (evo_twitch.h). Home's
* picture is the exception: what a screen can do then waits
* for its start, so the pad stays alive. */
if (hasInput && (!evo_twitch_launcher_busy() || evo_twitch_preview_starting())) {
m_screenManager->handleInput(pressed, held, released);
}
}
@@ -1193,15 +1239,32 @@ int Application::run() {
// 3. Determine if graphics needs to render/present
bool isPlayer = (m_screenManager->getCurrentScreenId() == ScreenId::Player);
/* Home's muted hero. The picture is the video quad, so player mode
* stays on while that stream is up even though this screen is not
* the player. Decode already follows evo_pb_presenting(), which the
* launcher sets for the preview before the first sample. */
const bool preview = !isPlayer && evo_twitch_preview_active();
const bool showVideo = isPlayer || preview;
static bool s_was_player = false;
if (isPlayer != s_was_player) {
evo_agc_runtime_set_player_mode(isPlayer ? 1 : 0);
s_was_player = isPlayer;
/* The player, or the picture inside Home (mode 2: no flash of both
* scanouts on the way in, see evo_agc_runtime.h). */
const int videoMode = isPlayer ? 1 : preview ? 2 : 0;
static int s_video_mode = 0;
if (videoMode != s_video_mode) {
evo_agc_runtime_set_player_mode(videoMode);
s_video_mode = videoMode;
}
/* The hero's art as fractions of the 1920x1080 page. Before
* frame_begin: a new box marks both scanouts dirty, and that clear
* has to land before the blit. */
if (preview && !m_softClose) {
evo_agc_runtime_set_video_rect(EVO_HUI_TWITCH_HERO_X / 1920.0f, EVO_HUI_TWITCH_HERO_Y / 1080.0f,
EVO_HUI_TWITCH_HERO_W / 1920.0f, EVO_HUI_TWITCH_HERO_H / 1080.0f);
}
bool hasAnim = evo::animation::AnimationManager::getInstance().hasActiveAnimations();
int uiActive = (frame < 10) || isPlayer || hasInput || hasAnim ||
int uiActive = (frame < 10) || showVideo || hasInput || hasAnim ||
evo_rmlui_needs_frame() || (jb_repaint > 0);
if (jb_repaint > 0) jb_repaint--;
evo_rmlui_set_active(uiActive);
@@ -1237,7 +1300,7 @@ int Application::run() {
// 4. Video Quad blit & Screen rendering
bool swap = false;
static int64_t s_last_pts = -1;
if (!isPlayer) {
if (!showVideo) {
s_last_pts = -1;
g_pace.stop();
}
@@ -1349,6 +1412,33 @@ int Application::run() {
swap = true;
}
} else {
/* The hero quad, into the scanout, before the page composites
* over it. The hole in the backdrop is transparent, so this is
* what shows through. Same blit as the player; the box above
* keeps it on the art. The page goes over the picture every
* frame (note_ui_drawn below), so each buffer is cleared before
* it is drawn into again and the picture is blitted afresh:
* nothing translucent (a dialog's dim, the rail's, a toast)
* piles up on it. */
if (preview && !m_softClose) {
pp_video_frame f;
std::memset(&f, 0, sizeof(f));
int have = (pp_playback_get_video_frame(&g_pp_pb, &f) && f.ready);
int64_t current_pts = g_pp_pb.display_pts_us;
bool new_frame = (current_pts != s_last_pts);
bool buffer_stale = have && evo_agc_runtime_video_slot_stale(current_pts);
if (have && (new_frame || buffer_stale)) {
s_last_pts = current_pts;
int is_direct = (evo_pb_active_backend() == EVO_VDEC_BACKEND_NATIVE && !f.held && f.uv != nullptr) ? 1 : 0;
evo_agc_blit_yuv(f.y, f.y_pitch, f.uv, f.uv_pitch,
f.u, f.u_pitch, f.v, f.v_pitch,
static_cast<int>(f.coded_w), static_cast<int>(f.coded_h),
static_cast<int>(f.disp_w), static_cast<int>(f.disp_h),
0, f.ten_bit, f.color_trc,
is_direct, current_pts, f.dovi);
swap = true;
}
}
if (m_uiScratch) {
if (frame < 5) {
evo_bt("frame %d: screenManager render begin", frame);
@@ -1379,6 +1469,10 @@ int Application::run() {
swap = evo_rmlui_consume_drew();
}
}
/* The page went over Home's picture: this buffer is cleared
* before its next use (see the blit above). */
if (preview && !m_softClose)
evo_agc_runtime_note_ui_drawn();
}
/*
@@ -7,6 +7,7 @@
#include "evo_vdec.h"
#include "evo_perf_monitor.h"
#include "evo_toast.h"
#include "evo_feedback.h"
#include "evo_boot_log.h"
#include "evo_agc_runtime.h"
#include "evo_hw.h"
@@ -93,6 +94,14 @@ constexpr int kChatBadgeH = 36;
/* An R1 press is written to the settings file once the presses stop. */
constexpr uint64_t kChatSaveDelayMs = 1500;
/* Picking a line to reply to pauses chat: the lines on screen then are
* kept (m_pausedLines), so a busy chat can't push the picked one out of the
* log. The picked line keeps this many newer lines under it on screen, so it
* never sits hidden at the cut top; and chat goes on by itself after
* kChatReplyIdleMs with no Up, Down or L1 (not while the keyboard is up). */
constexpr std::size_t kChatReplyBelow = 3;
constexpr uint64_t kChatReplyIdleMs = 30000;
/* How often the player asks for the channel playing again, for the OSD's
* viewer count (and a title or category that changed meanwhile). */
constexpr unsigned long long kNowPlayingRefreshMs = 60000;
@@ -282,6 +291,8 @@ void PlayerScreen::onExit() {
/* A picker, the exit dialog or another screen: an R1 change waiting to
* be written goes now. */
flushChatSave();
/* Chat goes on: a picked line doesn't wait behind another screen. */
resumeChat();
/* The clip card stays with the stream it was made on: it doesn't come
* back with the player. */
m_clipCardUntilMs = 0;
@@ -331,6 +342,7 @@ bool PlayerScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t release
if ((pressed & PadButtons::R1) && twitchPlaying(playback)) {
const twitch::ChatMode mode = twitch::nextChatMode(evo_twitch::settings().chatMode);
evo_twitch::set_chat_mode(mode);
resumeChat(); /* a line picked in the old layout */
m_chatSavePending = true;
m_chatSaveDueMs = NowMs() + kChatSaveDelayMs;
toast("Chat", chatModeName(mode));
@@ -339,12 +351,30 @@ bool PlayerScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t release
/* Triangle writes to the chat of a Twitch stream, whose aspect stays the
* Settings default. Before the controls' timer too: the OSD isn't needed
* for typing. Any other source keeps the aspect below. */
* for typing. Any other source keeps the aspect below. A reply highlight
* stays; Triangle itself is always a new message. */
if ((pressed & PadButtons::Triangle) && twitchPlaying(playback)) {
openChatKeyboard(playback);
return true;
}
/* L1 pauses chat on its newest line, Up/Down pick another, and L1 again
* opens the keyboard to reply to the picked one. Chat has to be on
* screen; with it off, L1 stays the swallowed live key below. Up and Down
* bring the controls up too, as they always have (the hint there says
* what L1 does). */
if (twitchPlaying(playback) && evo_twitch::settings().chatMode != twitch::ChatMode::Off) {
if (pressed & (PadButtons::Up | PadButtons::Down)) {
m_controlsLastUsedMs = NowMs();
moveChatReply((pressed & PadButtons::Up) ? -1 : 1);
return true;
}
if (pressed & PadButtons::L1) {
replyToChat(playback);
return true;
}
}
if (pressed) {
m_controlsLastUsedMs = NowMs();
}
@@ -436,7 +466,134 @@ bool PlayerScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t release
return false;
}
void PlayerScreen::openChatKeyboard(IPlaybackController* playback) {
namespace {
/* A viewer's line, with Twitch's id: status and notices have nothing to answer. */
bool chatReplyable(const twitch::ChatLine& line) {
return !line.id.empty() && !line.status && !line.notice;
}
/* Where the line with that serial is in the log, or -1 once it has left. */
long chatIndexOf(const std::deque<twitch::ChatLine>& lines, unsigned long long serial) {
for (std::size_t i = lines.size(); i-- > 0;) {
if (lines[i].serial == serial) return static_cast<long>(i);
if (lines[i].serial < serial) break; /* oldest first, rising serials */
}
return -1;
}
/* The newest line that can be replied to at or before `from`, or -1. */
long newestReplyable(const std::deque<twitch::ChatLine>& lines, long from) {
for (long i = from; i >= 0; --i)
if (chatReplyable(lines[static_cast<std::size_t>(i)])) return i;
return -1;
}
} // namespace
void PlayerScreen::pauseChat(const std::deque<twitch::ChatLine>& live) {
const std::size_t n = std::min(live.size(), kChatLines);
m_pausedLines.assign(live.end() - static_cast<std::ptrdiff_t>(n), live.end());
toast("Chat paused", "Up and Down pick a message, L1 replies to it");
}
void PlayerScreen::pickChatLine(const twitch::ChatLine& line) {
m_replySerial = line.serial;
m_replyUsedMs = NowMs();
evo_feedback(EVO_FB_MOVE);
}
void PlayerScreen::resumeChat() {
m_replySerial = 0;
m_pausedLines.clear();
}
bool PlayerScreen::startChatPick() {
const evo_twitch::ChatView& view = evo_twitch::chat_view();
if (!view.log) return false;
pauseChat(view.log->lines());
const long i = newestReplyable(m_pausedLines, static_cast<long>(m_pausedLines.size()) - 1);
if (i < 0) {
resumeChat();
return false;
}
pickChatLine(m_pausedLines[static_cast<std::size_t>(i)]);
return true;
}
/* Chat's channel changed (another stream) since a line was picked: chat
* goes on with the new one. */
void PlayerScreen::checkReplyChannel() {
const evo_twitch::ChatView& view = evo_twitch::chat_view();
if (!view.log || view.channel != m_replyChannel) {
m_replyChannel = view.log ? view.channel : std::string();
resumeChat();
}
}
void PlayerScreen::moveChatReply(int dir) {
checkReplyChannel();
if (!m_replySerial) {
/* Up pauses chat on its newest message; Down has nothing to do. */
if (dir > 0 || !startChatPick()) evo_feedback(EVO_FB_BOUNDARY);
return;
}
const std::deque<twitch::ChatLine>& lines = m_pausedLines;
const long current = chatIndexOf(lines, m_replySerial);
if (dir < 0) {
const long i = newestReplyable(lines, current - 1);
if (i < 0) {
evo_feedback(EVO_FB_BOUNDARY);
return;
}
pickChatLine(lines[static_cast<std::size_t>(i)]);
return;
}
for (long i = current + 1; i < static_cast<long>(lines.size()); ++i) {
if (chatReplyable(lines[static_cast<std::size_t>(i)])) {
pickChatLine(lines[static_cast<std::size_t>(i)]);
return;
}
}
/* Past the newest line it paused on: chat goes on. */
resumeChat();
evo_feedback(EVO_FB_MOVE);
}
void PlayerScreen::replyToChat(IPlaybackController* playback) {
checkReplyChannel();
/* Nothing picked yet: pause on the newest line first, so a fast chat
* can't swap who the reply goes to between the press and the keyboard. */
if (!m_replySerial) {
if (!startChatPick()) toast("Nothing to reply to", nullptr);
return;
}
const long i = chatIndexOf(m_pausedLines, m_replySerial);
if (i < 0) { /* can't happen: the paused copy holds it */
resumeChat();
return;
}
const twitch::ChatLine& chosen = m_pausedLines[static_cast<std::size_t>(i)];
m_replyUsedMs = NowMs();
const std::string& who = !chosen.name.empty() ? chosen.name : chosen.user;
openChatKeyboard(playback, chosen.id, who);
}
void PlayerScreen::tickChatReply() {
if (!m_replySerial) return;
checkReplyChannel();
if (m_replySerial && !evo_keyboard_is_open() && NowMs() - m_replyUsedMs >= kChatReplyIdleMs) resumeChat();
}
std::size_t PlayerScreen::chatWindowEnd(const std::deque<twitch::ChatLine>& lines) const {
if (!m_replySerial) return lines.size();
const long picked = chatIndexOf(lines, m_replySerial);
if (picked < 0) return lines.size();
return std::min(lines.size(), static_cast<std::size_t>(picked) + 1 + kChatReplyBelow);
}
void PlayerScreen::openChatKeyboard(IPlaybackController* playback, const std::string& replyId,
const std::string& replyName) {
const evo_twitch::AuthView& auth = evo_twitch::auth_view();
if (auth.state == evo_twitch::AuthState::Unknown) {
/* auth.json isn't read yet: a moment after start, or until the
@@ -451,9 +608,14 @@ void PlayerScreen::openChatKeyboard(IPlaybackController* playback) {
const PlaybackSource* src = playback->currentSource();
if (!src) return;
m_chatSendChannel = src->item_id;
char title[128];
std::snprintf(title, sizeof title, "Send a message to %s",
channelName(src->title, src->item_id).c_str());
m_chatReplyId = replyId;
char title[160];
if (!replyId.empty())
std::snprintf(title, sizeof title, "Reply to %s",
replyName.empty() ? "chat" : replyName.c_str());
else
std::snprintf(title, sizeof title, "Send a message to %s",
channelName(src->title, src->item_id).c_str());
/* A message to this channel that didn't go out in the last 2 minutes
* comes back for another try instead of being typed again. Peeked, not
* taken: cancelling the keyboard must not lose it. */
@@ -473,7 +635,10 @@ void PlayerScreen::OnChatSubmitted(const char* text, void* userdata) noexcept {
evo_twitch::chat_take_draft(self->m_chatSendChannel, &old);
/* Queued for the send worker; a failure comes back later as a
* notice, which Application toasts wherever the user is by then. */
const char* refused = evo_twitch::chat_send(self->m_chatSendChannel, text);
const char* refused = evo_twitch::chat_send(self->m_chatSendChannel, text, self->m_chatReplyId);
/* A message sent, a reply or not: chat goes on, so it shows up. */
self->resumeChat();
self->m_chatReplyId.clear();
if (refused) toast_error("Chat", refused); /* "Message not sent: ..." */
} catch (...) {
toast_error("Chat", "Message not sent: something went wrong");
@@ -566,6 +731,7 @@ void PlayerScreen::update(double deltaMs) {
m_playerFsm.update(deltaMs);
if (m_chatSavePending && NowMs() >= m_chatSaveDueMs) flushChatSave();
tickChatReply();
/* A clip made with Square: its card, or why there is none. The card goes
* by itself after its time. */
@@ -974,11 +1140,10 @@ void PlayerScreen::render(uint32_t* framebuffer, int width, int height) {
}
}
void PlayerScreen::syncChatUrls(bool badgesKnown) {
void PlayerScreen::syncChatUrls(bool badgesKnown, const std::deque<twitch::ChatLine>& lines, std::size_t end) {
const evo_twitch::ChatView& view = evo_twitch::chat_view();
const std::deque<twitch::ChatLine>& lines = view.log->lines();
const std::size_t n = std::min(lines.size(), kChatLines);
const std::size_t first = lines.size() - n;
const std::size_t n = std::min(end, kChatLines);
const std::size_t first = end - n;
/* Both run oldest first with rising serials: keep the entries of the
* lines still drawn, build the new ones, and let the rest go (with the
@@ -1042,23 +1207,30 @@ void PlayerScreen::renderChat(int kitMode, const std::string& title, const std::
m_chatSynced = false;
}
/* The log's lines up to the newest, or while a line is picked to
* reply to, the paused copy up to a few under it (chatWindowEnd). */
const bool paused = m_replySerial != 0;
const std::deque<twitch::ChatLine>& lines = paused ? m_pausedLines : view.log->lines();
const std::size_t end = chatWindowEnd(lines);
const unsigned long long endSerial = end > 0 ? lines[end - 1].serial : 0;
const bool badgesKnown = !view.badges->empty();
if (!m_chatSynced || view.log->revision() != m_chatRevision || badgesKnown != m_chatBadgesKnown) {
if (!m_chatSynced || view.log->revision() != m_chatRevision || badgesKnown != m_chatBadgesKnown ||
endSerial != m_chatEndSerial) {
m_chatSynced = false;
syncChatUrls(badgesKnown);
syncChatUrls(badgesKnown, lines, end);
m_chatRevision = view.log->revision();
m_chatBadgesKnown = badgesKnown;
m_chatEndSerial = endSerial;
m_chatSynced = true;
}
/* m_chatUrls holds the newest lines, one entry each, in order; the
* kit gets the newest few of them. */
const std::deque<twitch::ChatLine>& lines = view.log->lines();
if (m_chatUrls.size() > lines.size()) return;
/* m_chatUrls holds the lines before `end`, one entry each, in order;
* the kit gets the last few of them. */
if (m_chatUrls.size() > end) return;
const std::size_t n = std::min(m_chatUrls.size(),
kitMode == EVO_HUI_TWITCH_CHAT_SIDE ? kChatLinesSide : kChatLinesOverlay);
const std::size_t firstUrl = m_chatUrls.size() - n;
const std::size_t firstLine = lines.size() - n;
const std::size_t firstLine = end - n;
std::size_t segmentTotal = 0, badgeTotal = 0;
for (std::size_t k = 0; k < n; ++k) {
@@ -1090,6 +1262,7 @@ void PlayerScreen::renderChat(int kitMode, const std::string& title, const std::
out.reply_to = line.replyTo.c_str();
out.reply_login = line.replyToLogin.c_str();
out.mention = u.mention ? 1 : 0;
out.selected = (m_replySerial != 0 && line.serial == m_replySerial) ? 1 : 0;
for (std::size_t j = 0; j < line.segments.size(); ++j) {
evo_hui_twitch_chat_segment_t& o = m_chatSegments[seg++];
o.emote = (j < u.emotes.size() && !u.emotes[j].empty()) ? 1 : 0;
@@ -1142,6 +1315,7 @@ void PlayerScreen::renderChat(int kitMode, const std::string& title, const std::
p.video_h = static_cast<float>(vh) * sy;
}
p.channel = m_chatChannel.c_str();
p.paused = paused ? 1 : 0;
p.lines = n ? m_chatLines.data() : nullptr;
p.line_count = static_cast<int>(n);
switch (evo_twitch::settings().chatTextSize) {
@@ -36,6 +36,7 @@ enum class SettingKey : int {
ChatTextSize,
ThirdPartyEmotes,
LiveNotifications,
HomePreview,
/* Controls & sound */
Keyboard,
NavigationSounds,
@@ -70,6 +71,7 @@ struct SettingDef {
const SettingKey kTwitchKeys[] = {
SettingKey::TwitchAccount, SettingKey::DefaultQuality, SettingKey::Chat,
SettingKey::ChatTextSize, SettingKey::ThirdPartyEmotes, SettingKey::LiveNotifications,
SettingKey::HomePreview,
};
const SettingKey kControlsKeys[] = {
SettingKey::Keyboard, SettingKey::NavigationSounds, SettingKey::ControllerLight,
@@ -241,6 +243,12 @@ SettingDef makeDef(SettingKey key, ISettingsService* settings) {
"../icons/icon_bell.png", evo_twitch::settings().liveNotifications);
break;
/* Read by Home every frame: off, its hero shows a still picture. */
case SettingKey::HomePreview:
d = toggleDef(key, "Live preview on Home", "The top stream plays without sound at the top of Home",
"../icons/icon_tv.png", evo_twitch::settings().homePreview);
break;
case SettingKey::Keyboard:
d = valueDef(key, "Keyboard", "For search and chat messages", "../icons/icon_keyboard.png",
kKeyboardLabels, 2, settings->getKeyboardType() == EVO_KEYBOARD_TYPE_VIRTUAL ? 1 : 0);
@@ -370,6 +378,9 @@ void toggleSetting(SettingKey key) {
case SettingKey::LiveNotifications:
evo_twitch::set_live_notifications(!evo_twitch::settings().liveNotifications);
break;
case SettingKey::HomePreview:
evo_twitch::set_home_preview(!evo_twitch::settings().homePreview);
break;
default: return;
}
evo_feedback(EVO_FB_TOGGLE);
@@ -3,6 +3,7 @@
#include "evo/screens/TwitchScreenData.hpp"
#include "evo/Application.hpp"
#include "evo_rmlui_bridge.h"
#include "evo_twitch_settings.h"
#include <algorithm>
#include <cstring>
@@ -13,6 +14,39 @@ namespace {
constexpr int kCols = EVO_HUI_TWITCH_BROWSE_COLS;
} // namespace
namespace twitch_screen {
void LanguageStepper::step(bool next) {
const std::string from = shown();
m_code = next ? evo_twitch::next_browse_language(from) : evo_twitch::prev_browse_language(from);
m_pending = true;
m_dueMs = evo_twitch::data_now_ms() + kDelayMs;
evo_feedback(EVO_FB_MOVE);
const std::string label = evo_twitch::browse_language_label(m_code);
toast("Stream language", label.c_str());
}
std::string LanguageStepper::shown() const {
return m_pending ? m_code : evo_twitch::settings().browseLanguage;
}
bool LanguageStepper::apply(bool force) {
if (!m_pending || (!force && evo_twitch::data_now_ms() < m_dueMs)) return false;
m_pending = false;
if (m_code == evo_twitch::settings().browseLanguage) return false;
evo_twitch::set_browse_language(m_code);
evo_twitch::save_settings(evo_twitch::settings());
evo_twitch::request_home_streams();
return true;
}
std::string languageNote(const std::string& code) {
if (code.empty()) return std::string();
return evo_twitch::browse_language_label(code) + " streams";
}
} // namespace twitch_screen
TwitchBrowseScreen::TwitchBrowseScreen()
: StatefulScreen("TwitchBrowseScreen") {
}
@@ -23,6 +57,16 @@ void TwitchBrowseScreen::onEnter() {
refresh(false);
}
void TwitchBrowseScreen::onExit() {
m_language.apply(true);
StatefulScreen::onExit();
}
void TwitchBrowseScreen::update(double deltaMs) {
StatefulScreen::update(deltaMs);
m_language.apply(false);
}
void TwitchBrowseScreen::refresh(bool force) {
if (force || twitch_screen::due(evo_twitch::home_categories(), evo_twitch::data_now_ms()))
evo_twitch::request_home_categories();
@@ -84,6 +128,11 @@ bool TwitchBrowseScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t r
refresh(true);
return true;
}
/* L1 and R1 step the stream language (see the class comment). */
if (pressed & (PadButtons::L1 | PadButtons::R1)) {
m_language.step((pressed & PadButtons::R1) != 0);
return true;
}
if (pressed & PadButtons::Circle) {
evo_feedback(EVO_FB_CANCEL);
if (auto sm = Application::getInstance().getScreenManager()) sm->navigateBack(ScreenId::TwitchHome);
@@ -121,6 +170,9 @@ void TwitchBrowseScreen::render(uint32_t* framebuffer, int width, int height) {
m_pictures.endFrame();
p.cursor = m_cursor;
p.rail_focused = railFocused ? 1 : 0;
/* The categories are everyone's; the language is the streams' in them. */
m_languageLine = twitch_screen::languageNote(m_language.shown());
p.language = m_languageLine.c_str();
#if defined(EVO_AGC_DEVICE)
evo_hui_update_twitch_browse(&p);
@@ -3,6 +3,7 @@
#include "evo/screens/TwitchScreenData.hpp"
#include "evo/Application.hpp"
#include "evo_rmlui_bridge.h"
#include "evo_twitch_settings.h"
#include <algorithm>
#include <cstring>
@@ -46,12 +47,27 @@ void TwitchCategoryScreen::onEnter() {
if (m_tab == ClipsTab) refreshClips(false);
}
void TwitchCategoryScreen::onExit() {
m_language.apply(true);
StatefulScreen::onExit();
}
void TwitchCategoryScreen::update(double deltaMs) {
StatefulScreen::update(deltaMs);
/* L1/R1's language, once the presses stop: the live list again in it. */
if (m_language.apply(false)) refresh(false);
}
std::string TwitchCategoryScreen::liveKey() const {
return m_name + "\n" + evo_twitch::settings().browseLanguage;
}
void TwitchCategoryScreen::refresh(bool force) {
if (m_name.empty()) return;
if (force || m_requested != m_name ||
if (force || m_requested != liveKey() ||
twitch_screen::due(evo_twitch::category_streams(), evo_twitch::data_now_ms())) {
evo_twitch::request_category_streams(m_name);
m_requested = m_name;
m_requested = liveKey();
}
}
@@ -73,7 +89,7 @@ void TwitchCategoryScreen::refreshClips(bool force) {
}
int TwitchCategoryScreen::count() const {
if (m_name.empty() || m_requested != m_name) return 0;
if (m_name.empty() || m_requested != liveKey()) return 0;
return std::min<int>(static_cast<int>(evo_twitch::category_streams().value.size()),
EVO_HUI_TWITCH_CATEGORY_STREAMS);
}
@@ -87,7 +103,7 @@ int TwitchCategoryScreen::clipCount() const {
bool TwitchCategoryScreen::tabLoading() const {
if (m_tab == ClipsTab)
return !clipsOurs() || twitch_screen::status(evo_twitch::category_clips().status) == EVO_HUI_TWITCH_LOADING;
return m_requested != m_name ||
return m_requested != liveKey() ||
twitch_screen::status(evo_twitch::category_streams().status) == EVO_HUI_TWITCH_LOADING;
}
@@ -240,6 +256,12 @@ bool TwitchCategoryScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t
TwitchChannelScreen::show(login);
return true;
}
/* L1 and R1 step the stream language of the live list (and Home's, see
* TwitchBrowseScreen). Clips are in every language. */
if ((pressed & (PadButtons::L1 | PadButtons::R1)) && m_tab == LiveTab) {
m_language.step((pressed & PadButtons::R1) != 0);
return true;
}
if (pressed & PadButtons::Triangle) {
evo_feedback(EVO_FB_CONFIRM);
if (m_tab == ClipsTab)
@@ -263,7 +285,7 @@ void TwitchCategoryScreen::render(uint32_t* framebuffer, int width, int height)
}
const auto& streams = evo_twitch::category_streams();
const auto& clips = evo_twitch::category_clips();
if (m_requested == m_name) m_refreshToast.check(streams);
if (m_requested == liveKey()) m_refreshToast.check(streams);
if (clipsOurs()) m_clipsToast.check(clips);
keepFocusValid();
const int n = count();
@@ -285,7 +307,7 @@ void TwitchCategoryScreen::render(uint32_t* framebuffer, int width, int height)
p.status = EVO_HUI_TWITCH_ERROR;
p.error = "Pick a category in Browse";
} else {
p.status = m_requested == m_name ? twitch_screen::status(streams.status) : EVO_HUI_TWITCH_LOADING;
p.status = m_requested == liveKey() ? twitch_screen::status(streams.status) : EVO_HUI_TWITCH_LOADING;
/* As on a channel page: "Not found" alone says too little. */
p.error = streams.error == "Not found" ? "Category not found" : streams.error.c_str();
}
@@ -329,6 +351,8 @@ void TwitchCategoryScreen::render(uint32_t* framebuffer, int width, int height)
p.rail_focused = railFocused ? 1 : 0;
p.starting_login = m_startingLogin.empty() ? nullptr : m_startingLogin.c_str();
p.starting_id = m_startingId.empty() ? nullptr : m_startingId.c_str();
m_languageLine = twitch_screen::languageNote(m_language.shown());
p.language = m_languageLine.c_str();
#if defined(EVO_AGC_DEVICE)
evo_hui_update_twitch_category(&p);
@@ -2,6 +2,7 @@
#include "evo/screens/TwitchCategoryScreen.hpp"
#include "evo/screens/TwitchChannelScreen.hpp"
#include "evo/screens/TwitchScreenData.hpp"
#include "evo_twitch_settings.h"
#include "evo/Application.hpp"
#include "evo_rmlui_bridge.h"
@@ -61,8 +62,14 @@ TwitchHomeScreen::TwitchHomeScreen()
static_assert(Recent == EVO_HUI_TWITCH_ROW_RECENT, "Row must match EVO_HUI_TWITCH_ROW_*");
}
void TwitchHomeScreen::onExit() {
evo_twitch_preview_stop();
StatefulScreen::onExit();
}
void TwitchHomeScreen::onEnter() {
StatefulScreen::onEnter();
m_enteredMs = m_lastInputMs = evo_twitch::data_now_ms();
m_streamsToast.sync(evo_twitch::home_streams());
m_categoriesToast.sync(evo_twitch::home_categories());
m_followedToast.sync(evo_twitch::followed_live());
@@ -73,6 +80,27 @@ void TwitchHomeScreen::onEnter() {
void TwitchHomeScreen::update(double deltaMs) {
StatefulScreen::update(deltaMs);
followLogin();
syncPreview();
}
void TwitchHomeScreen::syncPreview() {
/* None with Settings' switch off, with nothing live, or once the pad has
* been left alone for a while: a page left up doesn't stream on (and
* keep the GPU busy) for hours. Any press brings it back. A quick pass
* through Home starts nothing. */
const auto& streams = evo_twitch::home_streams().value;
const unsigned long long now = evo_twitch::data_now_ms();
/* The rail takes the pad before this screen sees it: open, it counts as
* being used. */
if (auto sm = Application::getInstance().getScreenManager())
if (sm->isRailFocused()) m_lastInputMs = now;
if (streams.empty() || !evo_twitch::settings().homePreview || twitch_screen::fallbackActive() ||
now - m_lastInputMs >= kPreviewIdleMs) {
evo_twitch_preview_stop();
return;
}
if (now - m_enteredMs < kPreviewSettleMs) return;
evo_twitch_preview(streams[0].login.c_str());
}
void TwitchHomeScreen::followLogin() {
@@ -242,6 +270,10 @@ void TwitchHomeScreen::activate() {
TwitchCategoryScreen::show(c.name, c.viewers, c.boxArtUrl);
return;
}
if (m_row == Hero && evo_twitch_preview_take(login.c_str())) {
evo_feedback(EVO_FB_CONFIRM);
return;
}
if (twitch_screen::play(login, m_startingLogin)) m_startingLogin = login;
}
@@ -336,6 +368,7 @@ void TwitchHomeScreen::fillRecent(evo_hui_twitch_home_params_t& p) {
bool TwitchHomeScreen::handleInput(uint32_t pressed, uint32_t held, uint32_t released) {
(void)held;
(void)released;
m_lastInputMs = evo_twitch::data_now_ms();
if (twitch_screen::fallbackActive()) return twitch_screen::fallbackInput(pressed);
if (pressed & PadButtons::Up) {
@@ -479,6 +512,11 @@ void TwitchHomeScreen::render(uint32_t* framebuffer, int width, int height) {
p.recent_col = m_recentCol;
p.starting_login = m_startingLogin.empty() ? nullptr : m_startingLogin.c_str();
p.starting_id = m_startingId.empty() ? nullptr : m_startingId.c_str();
p.live_picture = p.stream_count > 0 ? evo_twitch_preview_picture(streams.value[0].login.c_str()) : 0;
/* Popular now is in the stream language picked on Browse. */
const std::string& language = evo_twitch::settings().browseLanguage;
m_streamsLanguage = language.empty() ? std::string() : evo_twitch::browse_language_label(language);
p.streams_language = m_streamsLanguage.c_str();
#if defined(EVO_AGC_DEVICE)
evo_hui_update_twitch_home(&p);
@@ -649,6 +649,7 @@ void PlaybackController::stopPlayback() {
first_audio_pts_seconds = -1.0;
first_video_pts_seconds = -1.0;
audio_seek_discard_until = -1.0;
evo_audio_out_reset_clock(1);
/* Don't let the next file's scrub previews reuse this file's demuxer. */
prospero_thumbnail_close_context();
@@ -717,8 +718,11 @@ bool PlaybackController::startPlaybackSource(const PlaybackSource& source,
/* evo_stream_io_open recorded why (wrong link, refused login, dead
* server, damaged file, a playlist where a stream belongs). */
char why[256];
toast_error(source.isProvider() ? "Couldn't open the stream" : "Couldn't open the file",
evo_error_take(why, sizeof why) ? why : nullptr);
const char* reason = evo_error_take(why, sizeof why) ? why : nullptr;
if (source.quiet)
evo_boot_log("open: quiet start failed: %s", reason ? reason : "no reason given");
else
toast_error(source.isProvider() ? "Couldn't open the stream" : "Couldn't open the file", reason);
return false;
}
@@ -803,9 +807,10 @@ bool PlaybackController::startPlaybackSource(const PlaybackSource& source,
evo_stream_io_close(m_streamIo);
m_streamIo = nullptr;
m_playbackFsm.postEvent(PlaybackEvent::Fail);
toast_error("Couldn't play this", probe_timed_out ? "Reading it took too long"
: no_streams ? "The server sent nothing playable"
: "No video or audio found in it");
if (!source.quiet)
toast_error("Couldn't play this", probe_timed_out ? "Reading it took too long"
: no_streams ? "The server sent nothing playable"
: "No video or audio found in it");
return false;
}
@@ -1182,7 +1187,7 @@ bool PlaybackController::startPlaybackSource(const PlaybackSource& source,
2 /* S16_8CH */);
if (handle >= 1) {
evo_audio_channels = 8;
} else {
} else if (!source.quiet) {
toast_tech("Audio", "Surround port refused, falling back to stereo");
}
}
@@ -1199,7 +1204,7 @@ bool PlaybackController::startPlaybackSource(const PlaybackSource& source,
evo_boot_log("audio: source %dch -> port %dch handle=%d",
chCount, evo_audio_channels, handle);
if (audio_handle < 1) {
if (audio_handle < 1 && !source.quiet) {
toast_error("No sound", "The PS5's audio output didn't start");
}
@@ -1211,6 +1216,7 @@ bool PlaybackController::startPlaybackSource(const PlaybackSource& source,
audio_queue_write = 0;
audio_queue_count = 0;
audio_accum_pos = 0;
evo_audio_out_reset_clock(1);
prospero_audio_resampler_reset();
@@ -63,6 +63,18 @@ void evo_data_path_rebind(void);
*/
int evo_mkdir(const char *path);
/*
* Where the diagnostics go: evo.log and, in --usb-remote builds, the dev
* remote's evo_cmd / evo_status / evo_ui.json. No trailing slash.
*
* The USB stick, /mnt/usb0, when one is in. A dev build (EVO_USB_REMOTE) with
* no stick falls back to <data root>/dev, so a console test doesn't need a
* stick; a release build only ever logs to the stick. Returns NULL while
* neither is writable (before the self-unjail, or a release build with no
* stick), and callers try again later. Cached once found.
*/
const char *evo_diag_dir(void);
#ifdef __cplusplus
}
#endif
@@ -110,6 +110,10 @@ int evo_agc_runtime_is_active(void);
void evo_agc_runtime_frame_begin(void);
void evo_agc_runtime_frame_end(void);
void evo_agc_runtime_present(void);
/* 0 the menus (every frame cleared), 1 the player (a buffer that held only
* video is not cleared), 2 a video picture inside a menu (Home's live
* picture): like 1, but going into it doesn't wipe the buffer on screen.
* Main thread. */
void evo_agc_runtime_set_player_mode(int is_player);
/* Mark the current backbuffer as carrying composited UI, so the next frame that
* reuses it clears first instead of stacking OSD/subtitle pixels. */
@@ -53,6 +53,7 @@ extern "C" {
/*
* Maximum audio lead over video presentation position, in seconds.
* (Both sides are PTS relative to first_video_pts_seconds.)
*
* One audio block is 2048 samples @ 48 kHz (~42.67 ms). 45 ms allows the
* audio driver to maintain double-buffered hardware output (~85 ms) while
@@ -74,10 +75,31 @@ extern volatile int audio_queue_write;
extern volatile int audio_queue_count;
extern volatile long long audio_samples_played;
extern volatile long long audio_samples_decoded;
/*
* The audio master clock: the PTS of the block last handed to the port,
* relative to first_video_pts_seconds (the first block's own PTS when there is
* no picture), so first_video_pts_seconds + audio_clock_seconds is the PTS of
* the audio playing. Steps once per block; silence on an underrun leaves it
* where it is. evo_av_clock.h has the details.
*/
extern volatile double audio_clock_seconds;
extern volatile double audio_pts_seconds;
extern double first_audio_pts_seconds;
extern int detected_audio_rate;
/* Decoded blocks dropped on a full queue since the stream opened. */
extern volatile unsigned audio_blocks_dropped;
/* Puts the clock's block-PTS bookkeeping back to the start: on open and stop
* (new_stream = 1, which also zeroes audio_blocks_dropped) and after a seek. */
void evo_audio_out_reset_clock(int new_stream);
/* 1: the output thread still advances the media clock, but plays silence.
* Home's live picture uses this so the stream stays in sync without sound. */
void evo_audio_set_silent(int on);
#ifdef EVO_USB_REMOTE
/* Dev remote `avdrop <ms>`: throw away that much decoded audio, once. */
void evo_audio_out_avdrop(int ms);
#endif
/*
* Seek discard gate, in stream seconds, or -1.0 when no seek is settling.
@@ -0,0 +1,121 @@
/*
* evo_av_clock.h - the audio master clock, kept on the audio's own timestamps.
*
* Video is paced against the audio (evo_audio_master_media_us). That clock
* used to count the samples played since the first block, so audio that never
* reached the speaker - a live HLS segment the demuxer skipped after a failed
* fetch, a decoded block dropped on a full queue - left the sound ahead of the
* picture by that much for the rest of the stream (#1). Now every PCM block
* carries the PTS of its first sample, and playing a block sets the clock to
* that PTS, the way ffplay and mpv do it.
*
* Three small pieces, all pure C with no FFmpeg or PS5 calls, so
* tests/test_runner.c runs them on the host:
* evo_apts_* the decode side: the PTS of each block as it is filled;
* evo_aclock_* the output side: the clock, from the block being played;
* evo_avsync_log_* when to write the "avsync" line to evo.log.
*/
#ifndef EVO_AV_CLOCK_H
#define EVO_AV_CLOCK_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* No timestamp. */
#define EVO_AV_NOPTS INT64_MIN
/*
* Audio that steps back by more than this is on a new timeline (the stream
* restarted, an ad was spliced in, the timestamps wrapped), not running late.
* The clock carries on from where it was and the anchor moves instead, so the
* relative clocks never go backwards. The video wait in decode_next_video_frame
* uses the same figure: a picture this far ahead of the sound is on the other
* side of such a jump, and waiting for the sound would only freeze it.
*/
#define EVO_AV_JUMP_US 1000000LL
/* ---------------------------------------------------------------- decode side
* Blocks are filled from decoded frames (mix_audio_frame_to_queue). Call
* evo_apts_frame() once per frame, before its samples, with the PTS of its
* first output sample (after the resampler's delay), or EVO_AV_NOPTS to carry
* on from the end of the frame before. Then evo_apts_write() for each run of
* samples copied into the block, and evo_apts_block() once the block is full. */
typedef struct evo_apts_writer {
int rate; /* output sample rate */
int64_t base_us; /* PTS of the write head `since` samples ago, or NOPTS */
int64_t since; /* samples written or skipped since base_us */
int64_t block_us; /* PTS of the first sample of the block being filled */
} evo_apts_writer;
void evo_apts_reset(evo_apts_writer *w, int rate);
void evo_apts_frame(evo_apts_writer *w, int64_t pts_us);
/* `n` samples go into a block that already held `fill`. */
void evo_apts_write(evo_apts_writer *w, int fill, int n);
/* `n` samples are thrown away (the dev remote's avdrop). */
void evo_apts_skip(evo_apts_writer *w, int n);
/* The PTS of the block being filled: its first sample's, or NOPTS. */
int64_t evo_apts_block(const evo_apts_writer *w);
/* ---------------------------------------------------------------- output side */
typedef struct evo_aclock {
int rate;
int block_samples;
int started;
int64_t base_us; /* PTS of the last block that had one */
int64_t since; /* samples played since base_us (blocks with no PTS) */
int64_t own_anchor; /* the anchor while there is no picture: the first block */
int stale; /* blocks thrown away since the reset (evo_aclock_stale) */
} evo_aclock;
void evo_aclock_reset(evo_aclock *c, int rate, int block_samples);
/*
* A block has been handed to the audio port. `pts_us` is its PTS, or NOPTS (it
* then follows the block before, one block's length on). `anchor_us` is the
* video's anchor, first_video_pts_seconds in microseconds, or NOPTS while no
* picture has been shown (the first block is the anchor then).
*
* Returns the clock in seconds: the block's PTS minus the anchor, so that
* anchor + clock is the PTS of the block playing. *shift_us is 0, except on a
* jump back past EVO_AV_JUMP_US: then the anchor moves back by -*shift_us,
* the clock carries on from the block before, and the caller moves
* first_video_pts_seconds by the same amount.
*/
double evo_aclock_play(evo_aclock *c, int64_t pts_us, int64_t anchor_us, int64_t *shift_us);
/* The PTS of the block playing now, or NOPTS before the first. */
int64_t evo_aclock_pts(const evo_aclock *c);
/*
* Whether to throw a block away instead of playing it: only the first block
* after a reset, and only when its PTS is more than EVO_AV_JUMP_US from the
* anchor. The decode thread isn't held during a seek, so a block it was
* filling can reach the queue after the seek cleared it; played first, it
* would set the clock to the old position. Gives up after EVO_AV_STALE_MAX
* blocks, so audio that really is that far from the picture still plays.
*/
#define EVO_AV_STALE_MAX 4
int evo_aclock_stale(evo_aclock *c, int64_t pts_us, int64_t anchor_us);
/* ---------------------------------------------------------------- the log line
* An "avsync" line goes to evo.log every EVO_AVSYNC_EVERY_MS while playing, and
* at once when the offset has moved more than EVO_AVSYNC_STEP_MS from the last
* line - but no closer than EVO_AVSYNC_GAP_MS to it, so a stall can't flood it. */
#define EVO_AVSYNC_EVERY_MS 10000
#define EVO_AVSYNC_STEP_MS 40.0
#define EVO_AVSYNC_GAP_MS 200
typedef struct evo_avsync_log {
long long last_ms; /* when the last line was written, -1 before the first */
double logged_ms; /* the offset it showed */
} evo_avsync_log;
void evo_avsync_log_reset(evo_avsync_log *s);
/* 1 when a line is due now for this offset (and it is then taken as written). */
int evo_avsync_log_due(evo_avsync_log *s, long long now_ms, double off_ms);
#ifdef __cplusplus
}
#endif
#endif /* EVO_AV_CLOCK_H */
@@ -46,6 +46,17 @@ double evo_pb_video_fps(void); /* 0 until a stream is open */
void evo_pb_queue_depth(int *vpkts, int *apkts, int *ablocks); /* debug overlay */
int evo_pb_active_backend(void); /* evo_vdec_backend of the live decoder */
/* 1 while frames should be decoded and the audio clock should run: the player
* screen, or a caller that asked (Home's muted live picture). The global
* `screen` stays whatever screen is really up. */
void evo_pb_set_presenting(int on);
int evo_pb_presenting(void);
/* 1 while nobody asked for what plays (Home's muted live picture): the
* pipeline's own toasts (connection lost, back online, decoder gave up) go
* to the log instead. */
void evo_pb_set_quiet(int on);
int evo_pb_quiet(void);
/* The audio-preferred media clock (kept under its historical name — many call
* sites). evo_pb_position_s() is the façade alias. */
double prospero_media_clock_seconds(void);
@@ -0,0 +1,39 @@
#ifndef EVO_SYSUI_H
#define EVO_SYSUI_H
/*
* evo_sysui - has the PS5 moved its focus away from the app (to its home
* screen, or to close the app)?
*
* Closing an app from the PS button's menu kills it outright. A kill while
* the app still had GPU work in flight panicked the console once
* (2026-09-18). On hardware (2026-10-19) the system moved the focus to
* itself 1-2 s before every such kill, so the frame loop stops drawing and
* drains the GPU the moment the focus leaves (Application.cpp), and the kill
* then finds the same quiet app the soft close leaves. The PS button's quick
* menu over a running app does not move the focus; nothing stops for it.
*
* The focus is ShellCore's "SceShellCoreUtilAppFocus" kernel event flag (its
* low 32 bits name the focused app, as ShadowMount+ reads it), compared with
* the app's own id from sceKernelGetAppInfo(). On 13.60 the system's own
* screen reads 0x7.
*/
#ifdef __cplusplus
extern "C" {
#endif
/* Opens the flag and reads the app's id, once, before the self-unjail (with
* evo_hw_probe()). What fails is logged, and the open is retried later. */
void evo_sysui_probe(void);
/* 1 while another app (the system) has the focus, 0 while this app has it,
* -1 when it can't tell (then the app carries on as before). One syscall a
* call; each change is logged. Main thread. */
int evo_sysui_focus_lost(void);
#ifdef __cplusplus
}
#endif
#endif /* EVO_SYSUI_H */
@@ -2356,6 +2356,7 @@ void evo_agc_runtime_set_player_mode(int is_player)
{
if (g_agc_dev.is_player_mode == is_player)
return;
const int was = g_agc_dev.is_player_mode;
g_agc_dev.is_player_mode = is_player;
/* whatever the last session drew says nothing about the next one */
g_video_px_valid = 0;
@@ -2370,10 +2371,18 @@ void evo_agc_runtime_set_player_mode(int is_player)
}
/* a new file decides HDR10 by its own first frame, not the last file's */
g_agc_dev.last_video_trc = -1;
if (is_player) {
if (is_player == 1 && was == 0) {
/* Clear both scanout buffers once upon entering player mode so letterbox borders are dark */
agc_clear_scanouts(0xff100d0du);
memset(g_agc_dev.stage_cache, 0, sizeof(g_agc_dev.stage_cache));
} else if (is_player) {
/* A picture inside a menu (2), or the player taking it over: the
* buffer on screen still shows the page and must not be wiped from
* the CPU, or the panel flashes dark. Both are cleared just before
* they are next drawn into instead, as a new video rect does. */
g_agc_dev.ui_dirty[0] = g_agc_dev.ui_dirty[1] = 1;
g_agc_dev.video_pts[0] = g_agc_dev.video_pts[1] = INT64_MIN;
memset(g_agc_dev.stage_cache, 0, sizeof(g_agc_dev.stage_cache));
}
}
+166 -15
View File
@@ -25,8 +25,10 @@
#include "evo_packet_queue.h"
#include "evo_audio_resample.h"
#include "evo_adec.h"
#include "evo_av_clock.h"
#include "pp_playback.h"
#include "evo_boot_log.h"
#include "evo_playback.h"
#ifndef SCREEN_PLAYER
#define SCREEN_PLAYER 2
@@ -34,6 +36,11 @@
int sceAudioOutOutput(int handle, const void *ptr);
/* Home's preview: output silence, keep the clock moving. */
static int g_audio_silent = 0;
void evo_audio_set_silent(int on) { g_audio_silent = on ? 1 : 0; }
/* ---------------------------------------------------------------------------
* TRANSITIONAL: playback-core and resume state still owned by main.c.
* Replaced by evo_pb_*() accessors at A8.
@@ -71,6 +78,8 @@ static int16_t audio_accum[2048 * EVO_AUDIO_MAX_CH];
int audio_accum_pos = 0;
static int16_t audio_queue[AUDIO_QUEUE_BLOCKS][AUDIO_BLOCK_SAMPLES * EVO_AUDIO_MAX_CH];
/* The PTS of each queued block's first sample, in microseconds (evo_av_clock.h). */
static int64_t audio_queue_pts[AUDIO_QUEUE_BLOCKS];
volatile int audio_queue_read = 0;
volatile int audio_queue_write = 0;
volatile int audio_queue_count = 0;
@@ -80,6 +89,7 @@ volatile double audio_clock_seconds = 0.0;
volatile double audio_pts_seconds = 0.0;
double first_audio_pts_seconds = -1.0;
volatile double audio_seek_discard_until = -1.0;
volatile unsigned audio_blocks_dropped = 0;
int detected_audio_rate = 48000;
volatile int audio_thread_running = 0;
@@ -88,8 +98,37 @@ pthread_t audio_thread;
volatile int audio_decode_thread_running = 0;
pthread_t audio_decode_thread;
/* The decode side's block PTS (decode thread), the clock (output thread) and
* the avsync line's timing (output thread). evo_audio_out_reset_clock() puts
* them back while neither thread is running, or across a seek. */
static evo_apts_writer g_apts = { PROSPERO_AUDIO_OUTPUT_RATE, EVO_AV_NOPTS, 0, EVO_AV_NOPTS };
static evo_aclock g_aclock = { PROSPERO_AUDIO_OUTPUT_RATE, AUDIO_BLOCK_SAMPLES, 0, EVO_AV_NOPTS, 0,
EVO_AV_NOPTS, 0 };
static evo_avsync_log g_avsync_log = { -1, 0.0 };
static void audio_queue_push(int16_t *buf) {
void evo_audio_out_reset_clock(int new_stream)
{
evo_apts_reset(&g_apts, PROSPERO_AUDIO_OUTPUT_RATE);
evo_aclock_reset(&g_aclock, PROSPERO_AUDIO_OUTPUT_RATE, AUDIO_BLOCK_SAMPLES);
evo_avsync_log_reset(&g_avsync_log);
if (new_stream)
audio_blocks_dropped = 0;
}
#ifdef EVO_USB_REMOTE
/* Dev remote `avdrop <ms>`: that much decoded audio is thrown away once, on
* the decode thread, the way a skipped segment or a dropped block loses it. */
static volatile int g_avdrop_ms = 0;
void evo_audio_out_avdrop(int ms)
{
if (ms > 0)
g_avdrop_ms = ms;
}
#endif
static void audio_queue_push(int16_t *buf, int64_t pts_us) {
int spins = 0;
/* Wait for space instead of dropping — drops cause choppy audio on UHD */
while (audio_queue_count >= AUDIO_QUEUE_BLOCKS &&
@@ -97,15 +136,68 @@ static void audio_queue_push(int16_t *buf) {
usleep(500);
spins++;
}
if (audio_queue_count >= AUDIO_QUEUE_BLOCKS)
if (audio_queue_count >= AUDIO_QUEUE_BLOCKS) {
/* Lost for good. The clock no longer minds (the next block brings its
* own PTS), but the avsync line counts them. */
audio_blocks_dropped++;
return;
}
memcpy(audio_queue[audio_queue_write], buf,
(size_t)AUDIO_BLOCK_SAMPLES * evo_audio_channels * sizeof(int16_t));
audio_queue_pts[audio_queue_write] = pts_us;
audio_queue_write = (audio_queue_write + 1) % AUDIO_QUEUE_BLOCKS;
audio_queue_count++;
}
/*
* The clock for the block just handed to the port: its PTS, relative to the
* picture's anchor, so first_video_pts_seconds + audio_clock_seconds is the
* PTS of the block playing - which is what evo_audio_master_media_us() hands
* the video pacing. It used to be a count of samples played, which never
* noticed audio that was lost on the way (#1).
*
* On a jump back to a new timeline the clock carries on and the anchor moves
* instead; first_video_pts_seconds moves with it, so every relative clock
* stays where it was and the video, whose timestamps jumped too, still lines
* up with the audio.
*/
static double audio_clock_from_block(int64_t pts_us)
{
const double anchor_s = first_video_pts_seconds;
const int64_t anchor_us = anchor_s >= 0.0 ? (int64_t)llround(anchor_s * 1000000.0) : EVO_AV_NOPTS;
int64_t shift_us = 0;
const double clock = evo_aclock_play(&g_aclock, pts_us, anchor_us, &shift_us);
if (shift_us != 0) {
if (anchor_us != EVO_AV_NOPTS && first_video_pts_seconds >= 0.0)
first_video_pts_seconds = (double)(anchor_us + shift_us) / 1000000.0;
evo_boot_log("avsync: audio timestamps went back %.3f s, new anchor",
(double)-shift_us / 1000000.0);
}
return clock;
}
/*
* The "avsync" line in evo.log: the PTS of the block now playing and of the
* picture on screen, both relative to the anchor, how far apart they are (the
* lip-sync offset, + is sound ahead) and how many decoded blocks were dropped.
* Every 10 s, and at once when the offset moves more than 40 ms
* (evo_av_clock.h). Nothing with no picture to compare (audio only).
*/
static void audio_note_avsync(void)
{
const double anchor = first_video_pts_seconds;
const int64_t shown_us = g_pp_pb.display_pts_us;
if (!g_pp_pb.active || shown_us <= 0 || anchor < 0.0)
return;
const double a = audio_clock_seconds;
const double v = (double)shown_us / 1000000.0 - anchor;
const double off_ms = (a - v) * 1000.0;
if (evo_avsync_log_due(&g_avsync_log, now_ms(), off_ms))
evo_boot_log("avsync a=%.3f v=%.3f off=%+.0fms drops=%u", a, v, off_ms,
(unsigned)audio_blocks_dropped);
}
void *audio_output_thread(void *arg) {
static int16_t silence[AUDIO_BLOCK_SAMPLES * EVO_AUDIO_MAX_CH];
/*
@@ -143,11 +235,12 @@ void *audio_output_thread(void *arg) {
usleep(2000);
continue;
}
if (screen == 2 && !player_paused && audio_handle >= 1) {
if (evo_pb_presenting() && !player_paused && audio_handle >= 1) {
/*
* Compare like-for-like: audio_clock is from t=0 of this session,
* video must be relative to first video PTS (not absolute PTS).
* Absolute compare freezes easy 720p/YouTube when clocks diverge.
* Compare like-for-like: audio_clock is the playing block's PTS
* relative to the first video PTS, so video must be relative to
* it too (not absolute PTS). Absolute compare freezes easy
* 720p/YouTube when clocks diverge.
*/
/*
* No anchor yet means nothing has been presented since the last
@@ -205,11 +298,25 @@ void *audio_output_thread(void *arg) {
continue;
}
if (audio_queue_count > 0) {
sceAudioOutOutput(audio_handle, audio_queue[audio_queue_read]);
const int64_t block_pts = audio_queue_pts[audio_queue_read];
const double anchor_s = first_video_pts_seconds;
if (evo_aclock_stale(&g_aclock, block_pts,
anchor_s >= 0.0 ? (int64_t)llround(anchor_s * 1000000.0)
: EVO_AV_NOPTS)) {
/* Decoded before the seek that just cleared the queue. */
evo_boot_log("avsync: skipped a block from before the seek (%.3f s, anchor %.3f s)",
(double)block_pts / 1000000.0, anchor_s);
audio_queue_read = (audio_queue_read + 1) % AUDIO_QUEUE_BLOCKS;
audio_queue_count--;
continue;
}
sceAudioOutOutput(audio_handle,
g_audio_silent ? silence : audio_queue[audio_queue_read]);
audio_samples_played += AUDIO_BLOCK_SAMPLES;
audio_clock_seconds = (double)audio_samples_played / 48000.0;
audio_clock_seconds = audio_clock_from_block(block_pts);
audio_queue_read = (audio_queue_read + 1) % AUDIO_QUEUE_BLOCKS;
audio_queue_count--;
audio_note_avsync();
} else {
/*
* Soft underrun: silence without advancing media clock.
@@ -263,8 +370,10 @@ static int16_t audio_float_to_s16(float v) {
return (int16_t)(v * 30000.0f);
}
/* pts_us: the frame's PTS in microseconds, or EVO_AV_NOPTS. */
static void mix_audio_frame_to_queue(
AVFrame *frame
AVFrame *frame,
int64_t pts_us
) {
if (
!frame ||
@@ -306,6 +415,17 @@ static void mix_audio_frame_to_queue(
return;
}
/*
* What comes out of swr_convert first is the input it still held from the
* frames before (`delay` samples at the input rate), so the first output
* sample is that much older than this frame. No PTS: the block PTS carries
* on from the samples already counted.
*/
evo_apts_frame(&g_apts,
pts_us == EVO_AV_NOPTS
? EVO_AV_NOPTS
: pts_us - av_rescale(delay, 1000000, input_rate));
/*
* Grow-only scratch instead of av_samples_alloc/av_freep per packet. This
* runs on every audio packet - about 43 a second for AAC - and the
@@ -345,6 +465,27 @@ static void mix_audio_frame_to_queue(
const int ch = evo_audio_channels;
int index = 0;
#ifdef EVO_USB_REMOTE
{
static int avdrop_left = 0; /* output samples still to throw away */
static int avdrop_ms = 0;
const int asked = g_avdrop_ms;
if (asked > 0) {
g_avdrop_ms = 0;
avdrop_ms = asked;
avdrop_left = (int)((long long)asked * PROSPERO_AUDIO_OUTPUT_RATE / 1000);
}
if (avdrop_left > 0) {
int n = converted < avdrop_left ? converted : avdrop_left;
evo_apts_skip(&g_apts, n);
index += n;
avdrop_left -= n;
if (avdrop_left == 0)
evo_boot_log("avdrop: threw away %d ms of decoded audio", avdrop_ms);
}
}
#endif
/*
* Both buffers are interleaved with the same channel count, so this is
* a straight copy - it just has to stop at each accumulator block
@@ -363,6 +504,7 @@ static void mix_audio_frame_to_queue(
&samples[(size_t)index * ch],
(size_t)run * (size_t)ch * sizeof(int16_t));
evo_apts_write(&g_apts, audio_accum_pos, run);
audio_accum_pos += run;
audio_samples_decoded += run;
index += run;
@@ -380,7 +522,7 @@ static void mix_audio_frame_to_queue(
break;
}
audio_queue_push(audio_accum);
audio_queue_push(audio_accum, evo_apts_block(&g_apts));
audio_accum_pos = 0;
}
}
@@ -407,7 +549,7 @@ void *audio_decode_thread_func(void *arg) {
* video is held would advance the clock against frames that have not
* been decoded yet, and every one of them would arrive late.
*/
if (player_paused || pb_prebuffer_hold || pb_scrub_hold || screen != 2) {
if (player_paused || pb_prebuffer_hold || pb_scrub_hold || !evo_pb_presenting()) {
/* Parked - see the note on the video decode thread. */
usleep(5000);
continue;
@@ -514,14 +656,18 @@ void *audio_decode_thread_func(void *arg) {
af->nb_samples = produced / (int)sizeof(int16_t) / ch;
if (af->nb_samples > 0 &&
av_frame_get_buffer(af, 0) == 0) {
int64_t pts_us = EVO_AV_NOPTS;
memcpy(af->data[0], g_adec_pcm, (size_t)produced);
if (pkt->pts != AV_NOPTS_VALUE && play_fmt && audio_stream_index >= 0) {
audio_pts_seconds = pkt->pts *
av_q2d(play_fmt->streams[audio_stream_index]->time_base);
if (first_audio_pts_seconds < 0.0)
first_audio_pts_seconds = audio_pts_seconds;
pts_us = av_rescale_q(pkt->pts,
play_fmt->streams[audio_stream_index]->time_base,
AV_TIME_BASE_Q);
}
mix_audio_frame_to_queue(af);
mix_audio_frame_to_queue(af, pts_us);
}
av_frame_unref(af);
}
@@ -530,13 +676,18 @@ void *audio_decode_thread_func(void *arg) {
if (!handled_natively && avcodec_send_packet(audio_ctx, pkt) == 0) {
while (audio_decode_thread_running && avcodec_receive_frame(audio_ctx, af) == 0) {
if (af->pts != AV_NOPTS_VALUE && play_fmt && audio_stream_index >= 0) {
audio_pts_seconds = af->pts * av_q2d(play_fmt->streams[audio_stream_index]->time_base);
int64_t pts_us = EVO_AV_NOPTS;
/* A frame's own PTS, else the decoder's guess from the DTS. */
const int64_t pts = af->pts != AV_NOPTS_VALUE ? af->pts : af->best_effort_timestamp;
if (pts != AV_NOPTS_VALUE && play_fmt && audio_stream_index >= 0) {
audio_pts_seconds = pts * av_q2d(play_fmt->streams[audio_stream_index]->time_base);
if (first_audio_pts_seconds < 0.0)
first_audio_pts_seconds = audio_pts_seconds;
pts_us = av_rescale_q(pts, play_fmt->streams[audio_stream_index]->time_base,
AV_TIME_BASE_Q);
}
mix_audio_frame_to_queue(af);
mix_audio_frame_to_queue(af, pts_us);
av_frame_unref(af);
}
}
@@ -0,0 +1,168 @@
/*
* evo_av_clock.c - see evo_av_clock.h.
*
* Positions are kept as a base PTS plus a sample count rather than as a
* running microsecond sum: one block is 42666.67 us, so adding whole
* microseconds per block would drift by a third of one every block.
*/
#include "evo_av_clock.h"
#include <stddef.h>
static int64_t samples_us(int64_t samples, int rate)
{
if (rate <= 0)
return 0;
return (samples * 1000000 + rate / 2) / rate;
}
/* ------------------------------------------------------------- decode side */
void evo_apts_reset(evo_apts_writer *w, int rate)
{
if (!w)
return;
w->rate = rate > 0 ? rate : 48000;
w->base_us = EVO_AV_NOPTS;
w->since = 0;
w->block_us = EVO_AV_NOPTS;
}
void evo_apts_frame(evo_apts_writer *w, int64_t pts_us)
{
if (!w || pts_us == EVO_AV_NOPTS)
return; /* no PTS: carry on from the end of the frame before */
w->base_us = pts_us;
w->since = 0;
}
static int64_t head_us(const evo_apts_writer *w)
{
if (w->base_us == EVO_AV_NOPTS)
return EVO_AV_NOPTS;
return w->base_us + samples_us(w->since, w->rate);
}
void evo_apts_write(evo_apts_writer *w, int fill, int n)
{
if (!w || n <= 0)
return;
if (fill == 0)
w->block_us = head_us(w);
w->since += n;
}
void evo_apts_skip(evo_apts_writer *w, int n)
{
if (w && n > 0)
w->since += n;
}
int64_t evo_apts_block(const evo_apts_writer *w)
{
return w ? w->block_us : EVO_AV_NOPTS;
}
/* ------------------------------------------------------------- output side */
void evo_aclock_reset(evo_aclock *c, int rate, int block_samples)
{
if (!c)
return;
c->rate = rate > 0 ? rate : 48000;
c->block_samples = block_samples > 0 ? block_samples : 2048;
c->started = 0;
c->base_us = EVO_AV_NOPTS;
c->since = 0;
c->own_anchor = EVO_AV_NOPTS;
c->stale = 0;
}
int64_t evo_aclock_pts(const evo_aclock *c)
{
if (!c || !c->started)
return EVO_AV_NOPTS;
return c->base_us + samples_us(c->since, c->rate);
}
int evo_aclock_stale(evo_aclock *c, int64_t pts_us, int64_t anchor_us)
{
int64_t gap;
if (!c || c->started || c->stale >= EVO_AV_STALE_MAX)
return 0;
if (pts_us == EVO_AV_NOPTS || anchor_us == EVO_AV_NOPTS)
return 0;
gap = pts_us > anchor_us ? pts_us - anchor_us : anchor_us - pts_us;
if (gap <= EVO_AV_JUMP_US)
return 0;
c->stale++;
return 1;
}
double evo_aclock_play(evo_aclock *c, int64_t pts_us, int64_t anchor_us, int64_t *shift_us)
{
int64_t shift = 0;
int64_t anchor;
if (shift_us)
*shift_us = 0;
if (!c)
return 0.0;
if (pts_us != EVO_AV_NOPTS) {
if (c->started) {
/* Where this block would start if nothing had been lost. */
const int64_t expected = c->base_us + samples_us(c->since + c->block_samples, c->rate);
if (pts_us < expected - EVO_AV_JUMP_US)
shift = pts_us - expected;
}
c->base_us = pts_us;
c->since = 0;
} else if (c->started) {
c->since += c->block_samples;
} else {
/* Nothing to go on yet: count from the anchor. */
c->base_us = anchor_us != EVO_AV_NOPTS ? anchor_us : 0;
c->since = 0;
}
if (!c->started) {
c->started = 1;
c->own_anchor = c->base_us;
}
c->own_anchor += shift;
anchor = anchor_us != EVO_AV_NOPTS ? anchor_us + shift : c->own_anchor;
if (shift_us)
*shift_us = shift;
return (double)(evo_aclock_pts(c) - anchor) / 1000000.0;
}
/* ------------------------------------------------------------- the log line */
void evo_avsync_log_reset(evo_avsync_log *s)
{
if (!s)
return;
s->last_ms = -1;
s->logged_ms = 0.0;
}
int evo_avsync_log_due(evo_avsync_log *s, long long now_ms, double off_ms)
{
int due;
if (!s)
return 0;
if (s->last_ms < 0) {
due = 1;
} else {
const long long since = now_ms - s->last_ms;
const double moved = off_ms > s->logged_ms ? off_ms - s->logged_ms : s->logged_ms - off_ms;
due = since >= EVO_AVSYNC_EVERY_MS || (moved > EVO_AVSYNC_STEP_MS && since >= EVO_AVSYNC_GAP_MS);
}
if (due) {
s->last_ms = now_ms;
s->logged_ms = off_ms;
}
return due;
}
+7 -3
View File
@@ -83,6 +83,7 @@ extern pp_playback g_pp_pb;
long long now_ms(void);
#include "evo_toast.h"
#include "evo_playback.h" /* evo_pb_quiet() */
/* ---------------------------------------------------------------------------
* Demux state (exported via evo_demux.h).
@@ -429,6 +430,8 @@ packet_queue_clear(
first_audio_pts_seconds = -1.0;
first_video_pts_seconds = -1.0;
/* The clock starts again from the first block at the target. */
evo_audio_out_reset_clock(0);
video_decode_done = 0;
dbg_read_fail = 0;
@@ -822,7 +825,7 @@ static void demux_recover(int read_result)
demux_recovering = 1;
/* Through the parallel reader the player has already said so
* (PlayerScreen watches the reader); one toast per outage, not two. */
if (demux_recover_tries == 1 && !pio_open())
if (demux_recover_tries == 1 && !pio_open() && !evo_pb_quiet())
toast_error("Connection lost", "Trying again\xE2\x80\xA6");
const AVStream *st = play_fmt->streams[video_stream_index];
char err[64];
@@ -857,7 +860,7 @@ static int demux_already_have(const AVPacket *pkt)
demux_last_vdts * av_q2d(play_fmt->streams[video_stream_index]->time_base));
demux_catching_up &= ~1;
demux_recovering = 0;
if (!pio_open())
if (!pio_open() && !evo_pb_quiet())
toast_success("Back online", "The stream is playing again");
return 0;
}
@@ -931,7 +934,8 @@ void *demux_thread_func(void *arg) {
demux_gave_up_told = 1;
demux_recovering = 0;
evo_boot_log("demux: gave up after %d tries - stream stopped", demux_recover_tries);
toast_error("The connection didn't come back", "Playback stopped");
if (!evo_pb_quiet())
toast_error("The connection didn't come back", "Playback stopped");
}
/*
* Keep the demux thread alive so seeking backward from EOF
@@ -31,12 +31,27 @@
#include "evo_packet_queue.h"
#include "evo_demux.h"
#include "evo_audio_out.h"
#include "evo_av_clock.h"
#include "evo_direct_mem.h"
#ifndef SCREEN_PLAYER
#define SCREEN_PLAYER 2
#endif
/* Home's live picture: decode and the audio clock run without screen == PLAYER. */
extern int screen;
static int g_pb_present_extra = 0;
void evo_pb_set_presenting(int on) { g_pb_present_extra = on ? 1 : 0; }
int evo_pb_presenting(void) { return screen == SCREEN_PLAYER || g_pb_present_extra; }
static volatile int g_pb_quiet = 0;
void evo_pb_set_quiet(int on) { g_pb_quiet = on ? 1 : 0; }
int evo_pb_quiet(void) { return g_pb_quiet; }
#define WIDTH 1920
#define HEIGHT 1080
@@ -117,7 +132,8 @@ static void note_vdec_result(int fatal)
#ifdef EVO_APP_MODULE
pp_stage_bc("P8_VDEC_FATAL", "decode failed repeatedly - ending playback");
#endif
toast_error("Can't play this", "The video couldn't be decoded");
if (!evo_pb_quiet())
toast_error("Can't play this", "The video couldn't be decoded");
}
volatile int video_thread_running = 0;
@@ -210,6 +226,9 @@ double prospero_media_clock_seconds(void)
return video_rel;
}
/* The PTS of the audio block playing (audio_clock_seconds is that PTS minus
* first_video_pts_seconds, evo_audio_out.h), in microseconds, or -1 while
* there is no audio clock to follow. */
int64_t evo_audio_master_media_us(void *user)
{
(void)user;
@@ -558,7 +577,15 @@ int decode_next_video_frame(void)
double audio_at_wait = audio_rel;
int stuck_iters = 0;
/*
* More than EVO_AV_JUMP_US ahead is not drift: the timestamps
* jumped (a new timeline after a stream restart or an ad) and
* the audio has already crossed to the other side, where it
* moved the anchor (evo_audio_out.c). Waiting here could never
* end that way; pp_clock still paces this frame.
*/
while (behind < -0.008 &&
behind > -(double)EVO_AV_JUMP_US / 1000000.0 &&
!player_paused &&
video_decode_ready &&
wait_iters < 100) {
@@ -735,7 +762,7 @@ void *video_decode_thread_func(void *arg) {
video_decode_hold ||
pb_prebuffer_hold ||
pb_scrub_hold ||
screen != SCREEN_PLAYER ||
!evo_pb_presenting() ||
!video_decode_ready
) {
video_decode_parked = 1;
@@ -0,0 +1,85 @@
/*
* evo_sysui.c - see evo_sysui.h.
*/
#include "evo_sysui.h"
#include "evo_boot_log.h"
#include <stdint.h>
#include <string.h>
#include <unistd.h>
int sceKernelOpenEventFlag(void *flag, const char *name);
int sceKernelPollEventFlag(void *flag, uint64_t pattern, uint32_t mode, uint64_t *result);
int sceKernelGetAppInfo(int pid, void *info);
#define FOCUS_FLAG_NAME "SceShellCoreUtilAppFocus"
#define EVF_WAITMODE_OR 2u
#define SYSTEM_APP_SHELLUI 0x7u /* what the focus reads while the system has it */
static int s_probed;
static void *s_focus_flag; /* the kernel event flag's handle */
static int s_focus_opened;
static int s_focus_open_rc = 1; /* the last open's result, for the log */
static int s_open_tries;
static uint32_t s_own_app_id; /* 0 while unknown */
static uint64_t s_last_focus = ~0ull;
static int s_last_poll_rc = 1;
static void open_focus_flag(void)
{
void *flag = NULL;
const int rc = sceKernelOpenEventFlag(&flag, FOCUS_FLAG_NAME);
if (rc != s_focus_open_rc)
evo_boot_log("sysui: open %s rc=%#x", FOCUS_FLAG_NAME, (unsigned)rc);
s_focus_open_rc = rc;
if (rc == 0) {
s_focus_flag = flag;
s_focus_opened = 1;
}
}
void evo_sysui_probe(void)
{
if (s_probed)
return;
s_probed = 1;
/* uint32 app id, uint64, then the title id: the layout ps5-payload-sdk
* declares (app_info_t). */
unsigned char info[256];
memset(info, 0, sizeof info);
const int pid = (int)getpid();
const int rc = sceKernelGetAppInfo(pid, info);
if (rc == 0)
memcpy(&s_own_app_id, info, sizeof s_own_app_id);
evo_boot_log("sysui: pid=%d app info rc=%#x app_id=%#x", pid, (unsigned)rc, (unsigned)s_own_app_id);
open_focus_flag();
}
int evo_sysui_focus_lost(void)
{
if (!s_focus_opened) {
/* Now and then: the open may only work once the sandbox is lifted. */
if ((s_open_tries++ % 120) == 0)
open_focus_flag();
if (!s_focus_opened)
return -1;
}
uint64_t value = 0;
const int rc = sceKernelPollEventFlag(s_focus_flag, ~0ull, EVF_WAITMODE_OR, &value);
if (rc != s_last_poll_rc || (rc == 0 && value != s_last_focus)) {
evo_boot_log("sysui: focus rc=%#x value=%#llx (own app id %#x)", (unsigned)rc,
(unsigned long long)value, (unsigned)s_own_app_id);
s_last_poll_rc = rc;
if (rc == 0)
s_last_focus = value;
}
if (rc != 0)
return -1;
const uint32_t focused = (uint32_t)value;
if (s_own_app_id)
return focused != s_own_app_id ? 1 : 0;
/* Own id unknown: only the system's own screen counts as away. */
return focused == SYSTEM_APP_SHELLUI ? 1 : 0;
}
@@ -417,6 +417,7 @@ static void note(const char *fmt, ...)
extern int g_pb_decode_fatal;
extern int video_decode_done;
#include "evo_toast.h"
#include "evo_playback.h" /* evo_pb_quiet() */
static volatile int64_t nwd_start_ms; /* 0 = no native call in flight */
static volatile int nwd_limit_ms;
@@ -448,7 +449,8 @@ static void *nwd_thread_main(void *arg)
nwd_what ? (const char *)nwd_what : "call", (long long)waited);
g_pb_decode_fatal = 1;
video_decode_done = 1;
toast_error("Playback stopped", "The video decoder stopped responding");
if (!evo_pb_quiet())
toast_error("Playback stopped", "The video decoder stopped responding");
}
return NULL;
}
+1 -1
View File
@@ -10,7 +10,7 @@
"conceptId": "99474",
"contentBadgeType": 1,
"contentId": "IV0000-PPSA99474_00-TWISPERO00000001",
"contentVersion": "01.000.002",
"contentVersion": "01.001.000",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
+25 -7
View File
@@ -88,12 +88,27 @@ void evo_log_redact(char *msg, size_t cap)
#include <time.h>
#include <unistd.h>
#include "evo_data_path.h"
#ifdef EVO_BOOT_TRACE_POPUP
struct bl_note { char pad[45]; char msg[3075]; };
extern int sceKernelSendNotificationRequest(int, void *, unsigned long, int);
#endif
#define EVO_LOG_PATH "/mnt/usb0/evo.log"
/* evo.log lives in evo_diag_dir(): the USB stick, or a dev build's
* /data/ps5twitch/dev when no stick is in. Empty until that is reachable. */
static char g_log_path[192];
static const char *bl_log_path(void)
{
if (!g_log_path[0]) {
const char *dir = evo_diag_dir();
if (!dir)
return NULL;
snprintf(g_log_path, sizeof g_log_path, "%s/evo.log", dir);
}
return g_log_path;
}
/*
* Two phases:
@@ -314,7 +329,7 @@ static void bl_vlog(const char *level, const char *fmt, va_list ap)
#ifdef EVO_BOOT_TRACE_POPUP
/* #51: on-screen popup — opt-in (--breadcrumbs). Off by default; the
* EVO_LOG_PATH file is the durable channel and is always written. */
* evo.log file is the durable channel and is always written. */
struct bl_note nt;
memset(&nt, 0, sizeof nt);
snprintf(nt.msg, sizeof nt.msg, "%s", line);
@@ -401,8 +416,10 @@ static FILE *bl_rotate(FILE *fp)
static char buf[64 * 1024];
fflush(fp);
fclose(fp);
FILE *in = fopen(EVO_LOG_PATH, "r");
FILE *out = in ? fopen(EVO_LOG_PATH ".1", "w") : NULL;
char old_path[200];
snprintf(old_path, sizeof old_path, "%s.1", g_log_path);
FILE *in = fopen(g_log_path, "r");
FILE *out = in ? fopen(old_path, "w") : NULL;
if (in && out) {
size_t n;
while ((n = fread(buf, 1, sizeof buf, in)) > 0)
@@ -410,7 +427,7 @@ static FILE *bl_rotate(FILE *fp)
}
if (in) fclose(in);
if (out) { fflush(out); fsync(fileno(out)); fclose(out); }
FILE *fresh = fopen(EVO_LOG_PATH, (in && out) ? "w" : "a"); /* keep it if the copy failed */
FILE *fresh = fopen(g_log_path, (in && out) ? "w" : "a"); /* keep it if the copy failed */
return fresh;
}
@@ -427,9 +444,10 @@ static void bl_session_header(FILE *fp, const char *why)
void evo_boot_log_flush(void)
{
if (!g_fp) {
FILE *fp = fopen(EVO_LOG_PATH, "a");
const char *path = bl_log_path();
FILE *fp = path ? fopen(path, "a") : NULL;
if (!fp)
return; /* /mnt/usb0 not reachable yet — try again next call */
return; /* the stick (or dev dir) not reachable yet — try again next call */
pthread_mutex_lock(&g_io_lock);
fseek(fp, 0, SEEK_END);
g_written = ftell(fp);
@@ -78,6 +78,51 @@ const char *evo_data_path(const char *leaf)
return buf;
}
/* True when a file can be created in `dir`. A probe write rather than
* access(): the app's libc has no access(), and with no stick /mnt/usb0 is a
* bare mount point on a read-only root that a stat would still find. */
static int dir_writable(const char *dir)
{
char probe[256];
FILE *f;
if (snprintf(probe, sizeof probe, "%s/.evo_probe", dir) >= (int)sizeof probe)
return 0;
f = fopen(probe, "w");
if (!f)
return 0;
fclose(f);
remove(probe);
return 1;
}
const char *evo_diag_dir(void)
{
static const char *dir;
if (dir)
return dir;
if (dir_writable("/mnt/usb0")) {
dir = "/mnt/usb0";
return dir;
}
#if defined(EVO_USB_REMOTE) && defined(EVO_APP_MODULE)
{
/* Only once the sandbox is open: before that the data root is the
* transient /download0 fallback, which isn't worth logging to. */
static char dev[160];
const char *root = evo_data_dir();
if (strncmp(root, "/data/", 6) == 0 &&
snprintf(dev, sizeof dev, "%s/dev", root) < (int)sizeof dev) {
evo_mkdir(dev);
if (dir_writable(dev)) {
dir = dev;
return dir;
}
}
}
#endif
return NULL;
}
int evo_mkdir(const char *path)
{
#ifdef EVO_APP_MODULE
+47 -5
View File
@@ -16,10 +16,12 @@
#include "evo_playback.h"
#include "evo_demux.h"
#include "evo_audio_out.h"
#include "pp_stage_breadcrumb.h"
#include "evo_rmlui_devstate.h"
#include "evo_keyboard.h"
#include "evo_twitch.h"
#include "evo_data_path.h"
#ifdef EVO_HAVE_BUILD_ID
#include "evo_build_id.h"
@@ -27,10 +29,29 @@
#define EVO_BUILD_ID "unknown"
#endif
#define CMD_PATH "/mnt/usb0/evo_cmd"
#define STATUS_PATH "/mnt/usb0/evo_status"
#define UI_PATH "/mnt/usb0/evo_ui.json"
#define UI_TMP_PATH "/mnt/usb0/evo_ui.json.tmp"
/* The remote's files live in evo_diag_dir(): the USB stick, or
* /data/ps5twitch/dev when no stick is in (tools/evo-remote.sh finds either). */
static char g_cmd_path[192], g_status_path[192], g_ui_path[192], g_ui_tmp_path[192];
#define CMD_PATH g_cmd_path
#define STATUS_PATH g_status_path
#define UI_PATH g_ui_path
#define UI_TMP_PATH g_ui_tmp_path
/* 1 once the paths are set; 0 while the stick and the dev dir are unreachable. */
static int remote_paths_ready(void)
{
const char *dir;
if (g_cmd_path[0])
return 1;
dir = evo_diag_dir();
if (!dir)
return 0;
snprintf(g_status_path, sizeof g_status_path, "%s/evo_status", dir);
snprintf(g_ui_path, sizeof g_ui_path, "%s/evo_ui.json", dir);
snprintf(g_ui_tmp_path, sizeof g_ui_tmp_path, "%s/evo_ui.json.tmp", dir);
snprintf(g_cmd_path, sizeof g_cmd_path, "%s/evo_cmd", dir); /* last: the ready flag */
return 1;
}
extern int screen; /* main.c */
@@ -195,6 +216,23 @@ static void run_command(const char *line)
-1.0 /* the base was never moved */);
return;
}
/*
* `avdrop <ms>` throws that much decoded audio away, once - what a skipped
* HLS segment or a dropped block does - to check the A/V sync fix (#1) on
* demand. Watch the "avsync" lines in evo.log: the offset jumps by <ms>
* and should be back near 0 within a second.
*/
if (strncmp(buf, "avdrop ", 7) == 0) {
char *end = NULL;
long ms;
errno = 0;
ms = strtol(buf + 7, &end, 10);
if (end == buf + 7 || *end != '\0' || errno != 0 || ms < 1 || ms > 10000)
pp_stage_bc("REMOTE_AVDROP?", buf + 7);
else
evo_audio_out_avdrop((int)ms);
return;
}
pp_stage_bc("REMOTE_CMD", "unknown");
}
@@ -268,6 +306,9 @@ void evo_usb_remote_poll(void)
static long long last_status = 0;
long long now = now_ms_local();
if (!remote_paths_ready())
return; /* no stick and the sandbox still shut: nowhere to talk yet */
/*
* Command file — consume then delete so each command runs once.
*
@@ -306,7 +347,8 @@ void evo_usb_remote_poll(void)
void evo_usb_remote_mark_parked(void)
{
write_status(now_ms_local(), 1);
if (remote_paths_ready())
write_status(now_ms_local(), 1);
}
#endif /* EVO_USB_REMOTE && EVO_APP_MODULE */
@@ -48,6 +48,30 @@ void evo_twitch_launcher_start(void) EVO_TWITCH_NOEXCEPT;
* Ignored while the app is quitting. Main thread only. */
void evo_twitch_play(const char* login) EVO_TWITCH_NOEXCEPT;
/* Home's small muted picture of the top live channel (see the launcher's
* file header). It starts only while the launcher is idle, nothing else
* plays and Home is up, in a low quality (480p or the closest below), with
* no sound, no toasts and no chat. Leaving Home stops it. It doesn't count
* as "Starting" or as "something else is playing": a play request stops it
* and goes ahead, and one made while it is still opening waits for that.
* One that fails, or dies once up, is dropped and holds the next try back
* (15 s, doubling to 5 minutes; 10 minutes for that channel). Calling it
* every frame is fine. Main thread only. */
void evo_twitch_preview(const char* login) EVO_TWITCH_NOEXCEPT;
void evo_twitch_preview_stop(void) EVO_TWITCH_NOEXCEPT;
/* 1 when that login's picture is the one on Home and a frame is ready. */
int evo_twitch_preview_picture(const char* login) EVO_TWITCH_NOEXCEPT;
/* 1 while that picture is up. The frame loop uses it to draw the video. */
int evo_twitch_preview_active(void) EVO_TWITCH_NOEXCEPT;
/* 1 while it is looked up or opening. busy() may be 1 then too, but the pad
* may still reach the screens: everything they can do waits for it. */
int evo_twitch_preview_starting(void) EVO_TWITCH_NOEXCEPT;
/* The picture is this login, plays already, and in the quality Settings
* would pick: unmute, remember the channel and open the player, without
* starting the stream again. 1 when it did that; 0 and the caller plays the
* channel as usual. */
int evo_twitch_preview_take(const char* login) EVO_TWITCH_NOEXCEPT;
/* ---- Videos and clips (stage 7) ---- */
/* What kind of video plays: Twitch's three kinds, and clips. 0 is a live
@@ -112,13 +136,16 @@ void evo_twitch_launcher_save_progress(void) EVO_TWITCH_NOEXCEPT;
/* 1 from a play request (or an autoplay that found its switch file) until the
* stream has started or failed; then evo_twitch_play() works again. For the
* screens' "Starting..." state. Covers busy() and the lookup before it, while
* input still reaches the screens. Main thread only. */
* input still reaches the screens. Home's picture doesn't count. Main thread
* only. */
int evo_twitch_launcher_starting(void) EVO_TWITCH_NOEXCEPT;
/* 1 while the start worker is inside PlaybackController::startPlaybackSource().
* The controller has no locking, so while this is 1 the main thread must not
* start or stop playback: Application holds the soft close's first stage,
* keeps pad input from the screens, and shutdown() waits. Main thread only. */
* keeps pad input from the screens (except while it is Home's picture that
* starts, see evo_twitch_preview_starting()), and shutdown() waits. Main
* thread only. */
int evo_twitch_launcher_busy(void) EVO_TWITCH_NOEXCEPT;
/* ---- What plays ---- */
@@ -100,13 +100,15 @@ inline constexpr int kChatSendQueueMax = 5;
// Queues text for channel's chat (a login), sent as the user logged in now
// (auth_view()); a message still waiting when someone else logs in isn't
// sent ("Message not sent: you switched Twitch accounts"). Line breaks and
// tabs become spaces. Returns nullptr when it was
// queued, and also for a blank message, which is simply not sent. Otherwise
// the toast saying why not: "Message not sent: log in under Settings to
// chat", "Message not sent: wait for the last ones to go out" (already
// kChatSendQueueMax), "Message not sent: sending isn't available in this
// build". Main thread only.
const char* chat_send(const std::string& channel, const std::string& text) noexcept;
// tabs become spaces. replyId, when not empty, is Twitch's id of the line
// this message answers (Helix reply_parent_message_id). Returns nullptr when
// it was queued, and also for a blank message, which is simply not sent.
// Otherwise the toast saying why not: "Message not sent: log in under
// Settings to chat", "Message not sent: wait for the last ones to go out"
// (already kChatSendQueueMax), "Message not sent: sending isn't available in
// this build". Main thread only.
const char* chat_send(const std::string& channel, const std::string& text,
const std::string& replyId = {}) noexcept;
// The notice of a message that failed, oldest first: moves it to *out and
// returns true. A notice the same as the one waiting before it isn't kept
@@ -36,5 +36,12 @@ void set_debug_overlay(bool on) noexcept;
void set_chat_text_size(twitch::ChatTextSize size) noexcept;
void set_third_party_emotes(bool on) noexcept;
void set_live_notifications(bool on) noexcept;
void set_home_preview(bool on) noexcept;
void set_browse_language(const std::string& code) noexcept; // not a listed code: ""
// The language after `code` in the Browse list ("" is "All languages"), the
// one before it, and the words for the toast and the page ("German").
std::string next_browse_language(const std::string& code) noexcept;
std::string prev_browse_language(const std::string& code) noexcept;
std::string browse_language_label(const std::string& code) noexcept;
} // namespace evo_twitch
@@ -91,6 +91,7 @@ struct Job {
std::string channel; // the login, lower case
std::string userId; // who was logged in when it was queued
std::string text;
std::string replyId; // the line this answers, or ""
};
// A failure for the main thread to toast.
@@ -231,7 +232,7 @@ bool sendOne(const Job& job, Ids& ids, std::string* reason, bool* vague) {
keepId(ids, job.channel, fresh);
id = &ids.back().second;
}
result = helix.sendChat(*id, userId, job.text);
result = helix.sendChat(*id, userId, job.text, job.replyId);
} catch (const twitch::UnauthorizedError& e) {
failure = e.missingScope() ? kScopeMissing : kTokenRefused;
snprintf(detail, sizeof detail, "%s", e.what());
@@ -281,7 +282,8 @@ bool sendOne(const Job& job, Ids& ids, std::string* reason, bool* vague) {
}
return false;
}
evo_log("twitch: chat: sent to %s in %llu ms", job.channel.c_str(), ms);
evo_log("twitch: chat: sent to %s in %llu ms%s", job.channel.c_str(), ms,
job.replyId.empty() ? "" : " (a reply)");
return true;
}
}
@@ -390,7 +392,8 @@ bool blank(const std::string& text) noexcept {
} // namespace
const char* chat_send(const std::string& channel, const std::string& text) noexcept {
const char* chat_send(const std::string& channel, const std::string& text,
const std::string& replyId) noexcept {
try {
if (blank(text)) return nullptr;
State& s = state();
@@ -399,6 +402,7 @@ const char* chat_send(const std::string& channel, const std::string& text) noexc
job.channel = channel;
for (char& c : job.channel) c = lowerAscii(c);
job.text = text;
job.replyId = replyId;
// One line: the IME's own keyboard can't type a line break, but a pasted
// or phone-sent text could carry one.
for (char& c : job.text)
@@ -55,6 +55,7 @@
#include "evo_boot_log.h"
#include "evo_twitch.h"
#include "evo_twitch_auth.h"
#include "evo_twitch_settings.h"
#include "evo_twitch_internal.h"
#include "twitch/app.hpp"
#include "twitch/browse.hpp"
@@ -437,7 +438,10 @@ std::vector<VideoCard> videoCards(const std::vector<twitch::VideoCard>& in) {
c.id = v.id;
c.title = v.title;
c.game = v.gameName;
c.thumbUrl = v.thumbnailUrl;
/* A broadcast still being recorded has the channel's live preview
* (fetchChannelVideos); it moves on every 5 minutes, as on a live card.
* Any other thumbnail comes back as it is. */
c.thumbUrl = twitch::liveThumbnailUrl(v.thumbnailUrl, now);
c.age = twitch::ageText(v.publishedAt, now);
c.published = v.publishedAt;
c.seconds = v.lengthSeconds;
@@ -586,7 +590,7 @@ void runJob(Job& job, FollowedLiveCache& cache) noexcept {
case kHomeStreams:
job.error = runPart(job, "", [&] {
twitch::GqlClient gql(evoTwitchHttp());
job.streams = streamCards(twitch::topStreams(gql, kHomeStreamCount));
job.streams = streamCards(twitch::topStreams(gql, kHomeStreamCount, job.arg));
return job.streams.size();
});
break;
@@ -600,8 +604,14 @@ void runJob(Job& job, FollowedLiveCache& cache) noexcept {
case kCategoryStreams:
job.error = runPart(job, "", [&] {
twitch::GqlClient gql(evoTwitchHttp());
job.streams =
streamCards(twitch::categoryStreams(gql, job.arg, kCategoryStreamCount));
std::string name = job.arg;
std::string lang;
const std::size_t split = job.arg.find('\n');
if (split != std::string::npos) {
name = job.arg.substr(0, split);
lang = job.arg.substr(split + 1);
}
job.streams = streamCards(twitch::categoryStreams(gql, name, kCategoryStreamCount, lang));
return job.streams.size();
});
break;
@@ -1075,9 +1085,22 @@ const Result<std::vector<StreamCard>>& followed_live() noexcept { return state()
const Result<std::vector<ChannelCard>>& followed_channels() noexcept { return state().followedChannels; }
const Result<ChannelPage>& now_playing() noexcept { return state().nowPlaying; }
void request_home_streams() noexcept { request(kHomeStreams, std::string()); }
void request_home_streams() noexcept {
try {
request(kHomeStreams, settings().browseLanguage);
} catch (...) {
evo_log_error("twitch: data: home streams request failed");
}
}
void request_home_categories() noexcept { request(kHomeCategories, std::string()); }
void request_category_streams(const std::string& name) noexcept { request(kCategoryStreams, name); }
void request_category_streams(const std::string& name) noexcept {
try {
const std::string& lang = settings().browseLanguage;
request(kCategoryStreams, lang.empty() ? name : name + "\n" + lang);
} catch (...) {
evo_log_error("twitch: data: category streams request failed");
}
}
void request_channel(const std::string& login) noexcept { request(kChannel, login); }
void request_now_playing(const std::string& login) noexcept { request(kNowPlaying, login); }
void request_followed_live(const std::string& userId) noexcept { requestFollowed(kFollowedLive, userId); }
@@ -25,6 +25,10 @@ twitch::HttpClient& evoTwitchHttpVerified();
// out). Main thread only. For launcher_host_test.cpp.
bool evoTwitchLauncherIdle() noexcept;
// Forgets Home's picture's failures, so the next one may start at once
// instead of after its wait. For launcher_host_test.cpp only.
void evoTwitchPreviewForgetFailures() noexcept;
// What evo_twitch_selftest() found: 1 passed, 0 failed, -1 not run yet.
int evoTwitchSelftestState() noexcept;
@@ -75,7 +75,9 @@
// or stop playback while the start worker is inside startPlaybackSource().
// evo_twitch_launcher_busy() says when that is, and Application uses it to:
// - hold the soft close's first stage (the one that calls stopPlayback()),
// - keep pad input away from the screens, so no tile starts something else,
// - keep pad input away from the screens, so no tile starts something else
// (not while it is Home's picture that opens: a play request then
// waits, see request()),
// - make shutdown() wait (up to 40 s) before it stops playback.
// When the worker returns it swaps g_start_owner Running -> Reported; from then
// on it never touches the controller again, so busy() drops and the main thread
@@ -119,6 +121,24 @@
// broadcast still being recorded has no length yet: the player treats it as
// live, it saves nothing, and its quality doesn't switch.
//
// Home's live picture (Kind::Preview, evo_twitch_preview()) takes the same
// path as a play request, with these differences:
// - it asks for kPreviewQuality, not Settings' quality, starts quiet
// (PlaybackSource::quiet: no toasts) with the sound silent, and never
// opens the player or joins chat;
// - it is the one start that leaves the pad alive: a play request while
// it starts waits (g_pending) and goes once the start worker is back,
// after the picture is stopped on the main thread;
// - every frame, previewTick() drops a picture that died (an error, the
// end of the stream, a decoder that gave up, no picture after
// kPreviewNoPictureMs) by stopping it on the main thread;
// - a lookup or start that fails, or a picture that dies, holds the next
// try back (kPreviewRetryMs, doubling up to kPreviewRetryMaxMs) and
// leaves that channel alone for kPreviewSkipMs;
// - X on the hero takes the picture over (evo_twitch_preview_take()) only
// when it already plays in the quality Settings would pick; otherwise
// the hero plays like any other card, and the picture stops first.
//
// Every piece of state here is file scope and trivially constructible (EVO
// keeps static initialization trivial). The one non-trivial object, the
// PlaybackSource, is handed to the start worker as its argument and freed by
@@ -160,6 +180,17 @@
#include "twitch/streams.hpp"
#include "twitch/videos.hpp"
/* Declared here rather than via the media headers: those pull in ffmpeg,
* and this file only needs the three switches for Home's muted picture and
* the two signs that it died. */
extern "C" {
void evo_audio_set_silent(int on);
void evo_pb_set_presenting(int on);
void evo_pb_set_quiet(int on);
int evo_pb_is_eof(void);
int evo_pb_decode_fatal(void);
}
namespace {
constexpr size_t kStackBytes = 2 * 1024 * 1024;
@@ -184,6 +215,17 @@ constexpr const char* kProviderVideo = "twitch-vod";
constexpr const char* kProviderClip = "twitch-clip";
// A video's position goes to its progress this often while it plays.
constexpr long long kProgressSaveMs = 15000;
// Home's live picture: small (an 800x450 box), so the closest rung at or
// below 480p30. After a failure the next try waits kPreviewRetryMs, twice
// that after a second one in a row, and so on up to kPreviewRetryMaxMs; the
// channel that failed is left alone for kPreviewSkipMs. A picture that has
// started but shows nothing for kPreviewNoPictureMs counts as failed.
constexpr const char* kPreviewQuality = "480p30";
constexpr const char* kPreviewClosing = "Closing once the live preview has stopped\xE2\x80\xA6";
constexpr long long kPreviewRetryMs = 15000;
constexpr long long kPreviewRetryMaxMs = 5 * 60 * 1000;
constexpr long long kPreviewSkipMs = 10 * 60 * 1000;
constexpr long long kPreviewNoPictureMs = 20000;
// ---- Mailbox: written by the resolve worker, read by the pump ----
enum Outcome : int { kOutNothing = 0, kOutStream = 1, kOutError = 2 };
@@ -202,6 +244,7 @@ struct Mailbox {
evo_twitch_quality_t qualities[EVO_TWITCH_QUALITY_MAX]; // twitch::qualityMenu()
int quality_count;
int quality_chosen; // the chosen variant's row, -1 if it has none
int quality_wanted; // Kind::Preview: the row Settings' quality would pick, -1 otherwise
evo_twitch_now_playing_t info; // the channel, for the OSD (empty for a quality switch)
};
pthread_mutex_t g_box_mx = PTHREAD_MUTEX_INITIALIZER;
@@ -231,13 +274,14 @@ struct VideoMeta {
// ---- What the resolve worker is asked to do ----
// Written by the main thread before it spawns the worker (pthread_create
// orders that), read by the worker; never while a worker runs.
enum class Kind { Auto, Play, Quality, Video, Clip };
enum class Kind { Auto, Play, Quality, Video, Clip, Preview };
Kind g_req_kind = Kind::Auto;
char g_req_id[96]; // Play: the normalized login; Video: the id; Clip: the slug;
// Quality: the item playing
Media g_req_media = Media::Live; // Kind::Quality: what is being switched
VideoMeta g_req_meta; // Kind::Video and Kind::Clip: the card's details
char g_req_quality[32]; // twitch::pickQuality(): Settings' default, or the picker's row
char g_req_wanted[32]; // Kind::Preview: Settings' default, to tell whether the picture is in it
// The autoplay check's decision, between the worker (Deciding -> On) and a
// play request on the main thread (Deciding -> StoodDown). One
@@ -271,6 +315,21 @@ struct Request {
enum class Phase { Idle, Resolving, Leaving, Starting };
Phase g_phase = Phase::Idle;
Kind g_kind = Kind::Auto; // what the current Resolving/Leaving/Starting is for
// Home's muted live picture. Main thread only. While it is up, a real play
// request stops it and goes ahead; it never counts as "something else".
bool g_home_preview = false;
bool g_preview_cancel = false; // leave Home, or a play request, during the lookup or start
// The picture plays in the quality Settings would pick, so X can take it over.
bool g_preview_full = false;
// Since when it has been up, and whether its first picture has shown yet.
long long g_preview_up_ms = 0;
bool g_preview_seen = false;
// Failures in a row, when the next try may come, and the channel that failed
// last with when it may be tried again (see kPreviewRetryMs).
int g_preview_failures = 0;
long long g_preview_wait_until_ms = 0;
char g_preview_failed[96];
long long g_preview_failed_until_ms = 0;
bool g_in_pump = false;
bool g_autoplay_tried = false; // evo_twitch_launcher_start() ran (once per launch)
evo::ScreenId g_auto_screen = evo::ScreenId::TwitchHome; // where the autoplay started
@@ -393,11 +452,24 @@ std::string toastName(const std::string& name, const std::string& login) {
return evo_twitch_text::channelName(name, login, true);
}
void dropHomePreview();
// Home's picture: no sound, decode while the player screen isn't up, and the
// media pipeline's toasts in the log only. All three on before it opens and
// off once it is dropped or taken over.
void previewSwitches(bool on) {
evo_audio_set_silent(on ? 1 : 0);
evo_pb_set_presenting(on ? 1 : 0);
evo_pb_set_quiet(on ? 1 : 0);
}
// Another source owns the player or is starting a stream of its own (see the
// top of the file): the toast for turning a play request down, or nullptr
// when it may go ahead. What an error or a finished file left behind does not
// count; the caller stops it before starting.
const char* otherPlayback(evo::IPlaybackController* pb, evo::ScreenManager* sm) {
// Home's muted picture is stopped by the caller before a real play.
if (g_home_preview) return nullptr;
if (sm->isPlaybackScreen(sm->getCurrentScreenId())) return kOtherPlaying;
switch (pb->getPlaybackState()) {
case evo::PlaybackState::Stopped:
@@ -458,6 +530,7 @@ void postNote(const std::string& note) {
struct Resolved {
twitch::Variant chosen;
std::vector<twitch::QualityOption> menu;
std::string wanted; // Kind::Preview: the variant Settings' quality would pick
};
// What the OSD shows of a channel: login and display name, and what Twitch
@@ -491,6 +564,7 @@ void postStream(const Resolved& r, const std::string& title, const std::string&
copyUtf8(g_box.item, sizeof g_box.item, item);
g_box.quality_count = 0;
g_box.quality_chosen = -1;
g_box.quality_wanted = -1;
for (const twitch::QualityOption& q : r.menu) {
if (g_box.quality_count >= EVO_TWITCH_QUALITY_MAX) break;
evo_twitch_quality_t& row = g_box.qualities[g_box.quality_count];
@@ -500,6 +574,7 @@ void postStream(const Resolved& r, const std::string& title, const std::string&
row.height = q.height;
row.fps = q.fps;
if (q.name == r.chosen.name) g_box.quality_chosen = g_box.quality_count;
if (!r.wanted.empty() && q.name == r.wanted) g_box.quality_wanted = g_box.quality_count;
++g_box.quality_count;
}
pthread_mutex_unlock(&g_box_mx);
@@ -581,6 +656,7 @@ Resolved resolveStream(twitch::GqlClient& gql, const std::string& login) {
Resolved r;
r.chosen = twitch::pickQuality(variants, g_req_quality);
r.menu = twitch::qualityMenu(variants);
if (g_req_wanted[0]) r.wanted = twitch::pickQuality(variants, g_req_wanted).name;
evo_log("twitch: picked %s (%s) %dx%d for \"%s\"", r.chosen.label.c_str(), r.chosen.name.c_str(),
r.chosen.width, r.chosen.height, g_req_quality);
if (r.chosen.uri.empty() || r.chosen.uri.size() >= sizeof g_box.url)
@@ -781,7 +857,7 @@ void* resolveWorker(void*) {
const char* user = evoTwitchRun(
[] {
twitch::GqlClient gql(evoTwitchHttp());
if (g_req_kind == Kind::Play)
if (g_req_kind == Kind::Play || g_req_kind == Kind::Preview)
resolvePlay(gql, g_req_id);
else if (g_req_kind == Kind::Video || g_req_kind == Kind::Clip)
resolveVideo(gql, g_req_id, mediaOf(g_req_kind));
@@ -837,14 +913,19 @@ void beginResolve(Kind kind, const char* id, const char* quality) {
g_req_kind = kind;
snprintf(g_req_id, sizeof g_req_id, "%s", id ? id : "");
snprintf(g_req_quality, sizeof g_req_quality, "%s", quality ? quality : "");
snprintf(g_req_wanted, sizeof g_req_wanted, "%s",
kind == Kind::Preview ? evo_twitch::settings().quality.c_str() : "");
g_auto_gate = kGateDeciding;
g_note_ready = false;
g_resolve_done = false;
if (!spawn(&g_resolve_thread, resolveWorker, nullptr)) {
showError(kind == Kind::Quality ? "Couldn't change the quality"
: kind == Kind::Video ? "Couldn't start the video"
: kind == Kind::Clip ? "Couldn't start the clip"
: "Couldn't start the stream");
if (kind != Kind::Preview)
showError(kind == Kind::Quality ? "Couldn't change the quality"
: kind == Kind::Video ? "Couldn't start the video"
: kind == Kind::Clip ? "Couldn't start the clip"
: "Couldn't start the stream");
else
evo_log_error("twitch: home preview: the lookup thread would not start");
return;
}
g_kind = kind;
@@ -852,9 +933,9 @@ void beginResolve(Kind kind, const char* id, const char* quality) {
}
// A new stream, video or clip starts in the default quality from Settings
// (read here: the settings are main thread only).
// (read here: the settings are main thread only). Home's picture is small.
void beginPlay(Kind kind, const char* id) {
beginResolve(kind, id, evo_twitch::settings().quality.c_str());
beginResolve(kind, id, kind == Kind::Preview ? kPreviewQuality : evo_twitch::settings().quality.c_str());
}
// A play request of any kind: its details for the worker, then the lookup.
@@ -892,15 +973,25 @@ bool launchStart(const Mailbox& box, Media media, const char* title, const char*
job->src.is_live = media == Media::Live;
// A past broadcast of a stream that is still on: from its start, not its end.
job->src.from_start = media == Media::Video;
// Home's picture: a failure is logged, not toasted over Home.
job->src.quiet = g_kind == Kind::Preview;
job->resume = media == Media::Live ? 0.0 : resume;
g_start_job = job;
g_start_ok = false;
g_start_owner = kRunning;
// Before the stream opens, so the first samples are silent and decode is
// not parked on the Home screen.
if (g_kind == Kind::Preview) previewSwitches(true);
if (!spawn(&g_start_thread, startWorker, g_start_job)) {
delete g_start_job;
g_start_job = nullptr;
showError("Playback failed");
if (g_kind == Kind::Preview) {
previewSwitches(false);
evo_log_error("twitch: home preview: the start thread would not start");
} else {
showError("Playback failed");
}
return false;
}
@@ -955,6 +1046,8 @@ bool okToStart(Kind kind, const Mailbox& box, evo::IPlaybackController* pb,
}
// A play request: the user asked for this channel, from whatever screen
// they are on now, unless another source took the player meanwhile.
// Home's picture, if it is still up, stops here so this stream can open.
if (g_home_preview) dropHomePreview();
if (const char* why = otherPlayback(pb, sm)) {
evo_log("twitch: not starting %s: screen %d, player state %d", box.item,
static_cast<int>(screen), static_cast<int>(state));
@@ -1101,6 +1194,122 @@ void pumpLeaving() {
showToast(note);
}
bool onTwitchHome() {
evo::ScreenManager* sm = evo::Application::getInstance().getScreenManager();
return sm && sm->getCurrentScreenId() == evo::ScreenId::TwitchHome;
}
// Drops the muted picture: silence and decode off, and its stream stopped.
// Main thread, never while the start worker is inside the controller (the
// callers check busy()).
void dropHomePreview() {
g_home_preview = false;
g_preview_full = false;
previewSwitches(false);
evo::IPlaybackController* pb = evo::Application::getInstance().getPlaybackController();
if (pb && pb->getPlaybackState() != evo::PlaybackState::Stopped) pb->stopPlayback();
g_playing.item[0] = '\0';
g_playing.quality_count = 0;
}
// The picture of `item` didn't open, or died: the next try waits, longer
// each time in a row, and that channel waits longer still.
void previewFailed(const char* item) {
const long long now = monoMs();
const int doublings = std::min(g_preview_failures, 5);
++g_preview_failures;
const long long wait = std::min(kPreviewRetryMs << doublings, kPreviewRetryMaxMs);
g_preview_wait_until_ms = now + wait;
copyUtf8(g_preview_failed, sizeof g_preview_failed, item ? item : "");
g_preview_failed_until_ms = now + kPreviewSkipMs;
evo_log("twitch: home preview: %s failed (%d in a row), the next try in %lld s", g_preview_failed,
g_preview_failures, wait / 1000);
}
// Whether a picture of `login` may be started now (see previewFailed()).
bool previewMayTry(const char* login) {
const long long now = monoMs();
if (now < g_preview_wait_until_ms) return false;
return now >= g_preview_failed_until_ms || strcmp(login, g_preview_failed) != 0;
}
void onPreviewResolved(const Mailbox& box) {
const bool cancel = g_preview_cancel || g_pending;
g_preview_cancel = false;
if (cancel) {
evo_log("twitch: home preview dropped");
startPending();
return;
}
if (box.outcome != kOutStream) {
// Offline since the list was made, for subscribers only, refused, no
// connection: each says why in the log, never in a toast.
evo_log("twitch: home preview: %s would not open (%s)", g_req_id, box.outcome == kOutError ? box.error : "-");
previewFailed(g_req_id);
return;
}
if (appQuitting() || !onTwitchHome()) {
evo_log("twitch: home preview: not on Home, not starting %s", box.item);
return;
}
// Only into an empty player. What an error or a finished stream left there
// is stopped here, on the main thread, as okToStart() does for a play
// request; anything else means something else plays, and it stays.
evo::IPlaybackController* pb = evo::Application::getInstance().getPlaybackController();
if (!pb) return;
const evo::PlaybackState state = pb->getPlaybackState();
if (state == evo::PlaybackState::Error || state == evo::PlaybackState::Finished) {
pb->stopPlayback();
} else if (state != evo::PlaybackState::Stopped) {
evo_log("twitch: home preview: the player is busy (state %d), not starting %s", static_cast<int>(state),
box.item);
return;
}
char still[160];
snprintf(still, sizeof still, "Still starting %s%s", box.name, kEllipsis);
if (!launchStart(box, Media::Live, box.title, box.name, box.info, nullptr, 0, still)) {
previewFailed(box.item);
return;
}
g_preview_full = box.quality_chosen >= 0 && box.quality_chosen == box.quality_wanted;
evo_log("twitch: home preview: starting %s in %s", box.item,
box.quality_chosen >= 0 ? box.qualities[box.quality_chosen].name : "?");
}
// Every frame, while the picture is up: it is dropped once it has died (an
// error, the end of the stream, a decoder that gave up, or no picture for
// kPreviewNoPictureMs), stopped here on the main thread, and the next try
// waits. A picture that shows clears the failures. Never while busy().
void previewTick() {
if (!g_home_preview || evo_twitch_launcher_busy()) return;
evo::IPlaybackController* pb = evo::Application::getInstance().getPlaybackController();
char item[sizeof g_playing.item];
memcpy(item, g_playing.item, sizeof item);
const char* why = nullptr;
if (!ownStreamPlaying(pb))
why = "it stopped";
else if (evo_pb_decode_fatal())
why = "the decoder gave up";
else if (evo_pb_is_eof())
why = "the stream ended";
else if (pb->isPictureReady()) {
if (!g_preview_seen) {
g_preview_seen = true;
g_preview_failures = 0;
evo_log("twitch: home preview: %s shows", item);
}
return;
} else if (!g_preview_seen && monoMs() - g_preview_up_ms >= kPreviewNoPictureMs) {
why = "no picture came";
} else {
return;
}
evo_log("twitch: home preview: %s dropped, %s (player state %d)", item, why,
pb ? static_cast<int>(pb->getPlaybackState()) : -1);
dropHomePreview();
previewFailed(item);
}
void onResolved() {
pthread_join(g_resolve_thread, nullptr);
g_phase = Phase::Idle; // unless a start worker is launched below
@@ -1115,6 +1324,10 @@ void onResolved() {
onQualityResolved(box);
return;
}
if (kind == Kind::Preview) {
onPreviewResolved(box);
return;
}
const Media media = mediaOf(kind);
if (box.outcome != kOutStream) {
@@ -1168,6 +1381,32 @@ void onStarted() {
evo::IPlaybackController* pb = app.getPlaybackController();
evo::ScreenManager* sm = app.getScreenManager();
g_playing.quality_count = 0;
if (g_kind == Kind::Preview) {
// Left Home, quitting, or a play request came in while it started: the
// picture goes, and the play request (if any) goes next.
const bool wanted = !appQuitting() && !g_preview_cancel && !g_pending && onTwitchHome();
if (g_start_ok && wanted) {
evo_log("twitch: home preview: %s is up", g_starting.item);
memcpy(&g_playing, &g_starting, sizeof g_playing);
g_home_preview = true;
g_preview_up_ms = monoMs();
g_preview_seen = false;
} else {
if (g_start_ok) {
evo_log("twitch: home preview: stopping %s", g_starting.item);
} else {
evo_log_error("twitch: home preview: %s didn't start", g_starting.item);
previewFailed(g_starting.item);
}
// A failed start can leave Error behind: stopped too, so the next
// start finds an empty player.
dropHomePreview();
}
g_preview_cancel = false;
startPending();
evo_boot_log_flush();
return;
}
if (g_start_ok) {
if (appQuitting()) {
// The held soft close goes ahead next frame; it must not find us playing.
@@ -1218,8 +1457,10 @@ void pumpStarting() {
}
if (g_start_owner.load() == kRunning) {
const long long now = monoMs();
if (now - g_start_toast_ms >= 3000) {
showToast(g_start_still);
// Home's picture says nothing while it opens, unless the user asked to
// quit: the soft close waits for it, and the screen shouldn't look dead.
if ((g_kind != Kind::Preview || appQuitting()) && now - g_start_toast_ms >= 3000) {
showToast(g_kind == Kind::Preview ? kPreviewClosing : g_start_still);
g_start_toast_ms = now;
}
if (!g_start_slow_logged && now - g_start_t0_ms >= 45000) {
@@ -1265,12 +1506,25 @@ void request(const Request& req) {
g_auto_gate.load() == kGateDeciding;
const bool qualityLookup = g_phase == Phase::Resolving && g_kind == Kind::Quality;
const bool qualityLeaving = g_phase == Phase::Leaving;
if ((g_phase != Phase::Idle && !autoDeciding && !qualityLookup && !qualityLeaving) || g_pending ||
g_in_pump) {
const bool previewLookup = g_phase == Phase::Resolving && g_kind == Kind::Preview;
const bool previewStarting = g_phase == Phase::Starting && g_kind == Kind::Preview;
if ((g_phase != Phase::Idle && !autoDeciding && !qualityLookup && !qualityLeaving && !previewLookup &&
!previewStarting) ||
g_pending || g_in_pump) {
evo_log("twitch: already starting a stream, ignoring the play request for %s", req.id);
showToast(alreadyStarting());
return;
}
if (previewStarting) {
// Home's picture is opening on the start worker, so the controller can't
// be looked at now. The request waits; onStarted() stops the picture on
// the main thread and lets it go.
g_preview_cancel = true;
g_pending_req = req;
g_pending = true;
evo_log("twitch: play request for %s, waiting for the home preview to stand down", g_pending_req.id);
return;
}
if (qualityLeaving) {
// The switch has left the player and stops its stream on the next pump
// call; it drops itself there and this request goes next. Until then
@@ -1309,6 +1563,13 @@ void request(const Request& req) {
evo_log("twitch: play request for %s, the autoplay check stands down", g_pending_req.id);
return;
}
if (previewLookup) {
g_preview_cancel = true;
g_pending_req = req;
g_pending = true;
evo_log("twitch: play request for %s, the home preview stands down", g_pending_req.id);
return;
}
if (qualityLookup) {
// The stream being switched has stopped (otherPlayback() passed), and
// the switch is dropped once its lookup is back (onQualityResolved()).
@@ -1467,7 +1728,7 @@ void pumpResolving() {
pthread_mutex_lock(&g_box_mx);
memcpy(note, g_box.note, sizeof note);
pthread_mutex_unlock(&g_box_mx);
if (!appQuitting()) showToast(note);
if (!appQuitting() && g_kind != Kind::Preview) showToast(note);
}
if (g_resolve_done) onResolved();
}
@@ -1493,6 +1754,120 @@ extern "C" void evo_twitch_launcher_start(void) noexcept {
}
}
extern "C" void evo_twitch_preview(const char* login) noexcept {
try {
if (!login || !login[0] || appQuitting()) return;
if (g_phase != Phase::Idle || g_pending || evo_twitch_launcher_busy()) return;
if (!onTwitchHome()) return;
std::string normalized;
try {
normalized = twitch::normalizeLogin(login);
} catch (const twitch::NotFoundError&) {
return;
}
if (g_home_preview && strcmp(g_playing.item, normalized.c_str()) == 0) return;
evo::IPlaybackController* pb = evo::Application::getInstance().getPlaybackController();
if (!pb) return;
if (g_home_preview) {
// Another channel is on top now: the old picture goes, whether or not
// the new one may start yet.
evo_log("twitch: home preview: %s is no longer on top", g_playing.item);
dropHomePreview();
}
if (!previewMayTry(normalized.c_str())) return;
// Something of the user's own plays (or opens): it stays. What an error
// or the end of a stream left is stopped once the lookup is back
// (onPreviewResolved()).
const evo::PlaybackState state = pb->getPlaybackState();
if (state != evo::PlaybackState::Stopped && state != evo::PlaybackState::Error &&
state != evo::PlaybackState::Finished)
return;
if (!selftestPassed()) return;
g_preview_cancel = false;
beginPlay(Kind::Preview, normalized.c_str());
if (g_phase != Phase::Resolving) previewFailed(normalized.c_str()); // the worker didn't start
} catch (...) {
evo_log_error("twitch: home preview request failed");
}
}
extern "C" void evo_twitch_preview_stop(void) noexcept {
try {
if (g_kind == Kind::Preview && (g_phase == Phase::Resolving || g_phase == Phase::Starting)) {
// Still looking it up or opening it: onPreviewResolved() or onStarted()
// drops it once the worker is back.
g_preview_cancel = true;
return;
}
if (evo_twitch_launcher_busy()) return; // another start; the picture isn't up
if (g_home_preview) {
evo_log("twitch: home preview: stopping %s", g_playing.item);
dropHomePreview();
}
} catch (...) {
evo_log_error("twitch: home preview stop failed");
}
}
extern "C" int evo_twitch_preview_active(void) noexcept { return g_home_preview ? 1 : 0; }
extern "C" int evo_twitch_preview_starting(void) noexcept {
return g_kind == Kind::Preview && (g_phase == Phase::Resolving || g_phase == Phase::Starting) ? 1 : 0;
}
extern "C" int evo_twitch_preview_picture(const char* login) noexcept {
try {
if (!g_home_preview || !login || !login[0] || evo_twitch_launcher_busy()) return 0;
if (strcmp(g_playing.item, login) != 0) {
std::string normalized;
try {
normalized = twitch::normalizeLogin(login);
} catch (...) {
return 0;
}
if (normalized != g_playing.item) return 0;
}
evo::IPlaybackController* pb = evo::Application::getInstance().getPlaybackController();
return pb && pb->isPictureReady() ? 1 : 0;
} catch (...) {
return 0;
}
}
extern "C" int evo_twitch_preview_take(const char* login) noexcept {
try {
if (!g_home_preview || !login || !login[0] || evo_twitch_launcher_busy()) return 0;
// Another play request is being looked up: it goes first (and the
// caller's own request is turned down as usual).
if (g_phase != Phase::Idle || g_pending) return 0;
// A picture in a lower quality than Settings asks for is not taken over:
// the caller plays the channel, which stops the picture first.
if (!g_preview_full) return 0;
std::string normalized;
try {
normalized = twitch::normalizeLogin(login);
} catch (const twitch::NotFoundError&) {
return 0;
}
if (strcmp(g_playing.item, normalized.c_str()) != 0) return 0;
evo::Application& app = evo::Application::getInstance();
evo::IPlaybackController* pb = app.getPlaybackController();
evo::ScreenManager* sm = app.getScreenManager();
if (!pb || !sm || !ownStreamPlaying(pb) || appQuitting()) return 0;
g_home_preview = false;
g_preview_full = false;
previewSwitches(false);
sm->navigateTo(evo::ScreenId::Player);
if (g_playing.media == Media::Live && g_playing.info.login[0])
evo_twitch::history_record_channel(g_playing.info.login, g_playing.info.name, g_playing.info.avatar_url);
evo_log("twitch: home preview: opening %s in the player", g_playing.item);
return 1;
} catch (...) {
evo_log_error("twitch: home preview open failed");
return 0;
}
}
extern "C" void evo_twitch_play(const char* login) noexcept {
try {
if (!login || !login[0]) {
@@ -1568,11 +1943,12 @@ extern "C" void evo_twitch_play_clip(const char* slug, const evo_twitch_video_in
extern "C" void evo_twitch_launcher_pump(void) noexcept {
if (g_in_pump) return;
// Every frame, whatever the phase: the pause a quality switch carries
// over, a video's position for its progress, and a history save that
// waited for the one before it.
// over, a video's position for its progress, Home's picture if it died,
// and a history save that waited for the one before it.
try {
pauseTick();
progressTick();
previewTick();
evo_twitch::history_frame();
} catch (...) {
evo_log_error("twitch: progress tick failed");
@@ -1610,13 +1986,23 @@ extern "C" void evo_twitch_launcher_save_progress(void) noexcept {
bool evoTwitchLauncherIdle() noexcept { return g_phase == Phase::Idle && !g_pending; }
void evoTwitchPreviewForgetFailures() noexcept {
g_preview_failures = 0;
g_preview_wait_until_ms = 0;
g_preview_failed_until_ms = 0;
}
extern "C" int evo_twitch_launcher_busy(void) noexcept {
// g_phase is main-thread state, which is why this is main thread only.
return g_phase == Phase::Starting && g_start_owner.load() == kRunning ? 1 : 0;
}
extern "C" int evo_twitch_launcher_starting(void) noexcept {
if (g_pending || g_phase == Phase::Starting || g_phase == Phase::Leaving) return 1;
if (g_pending) return 1;
// Home's picture isn't one the user asked for: not a start, looked up or
// opening.
if (g_kind == Kind::Preview) return 0;
if (g_phase == Phase::Starting || g_phase == Phase::Leaving) return 1;
if (g_phase != Phase::Resolving) return 0;
// The autoplay check is not a start until it has found the switch file, and
// a quality switch's lookup isn't one either: the old quality still plays.
@@ -14,6 +14,7 @@
#include <string>
#include "evo_boot_log.h"
#include "twitch/browse.hpp"
#include "evo_data_path.h"
#include "evo_jailbreak.h"
@@ -92,6 +93,38 @@ void set_debug_overlay(bool on) noexcept { loaded().debugOverlay = on; }
void set_chat_text_size(twitch::ChatTextSize size) noexcept { loaded().chatTextSize = size; }
void set_third_party_emotes(bool on) noexcept { loaded().thirdPartyEmotes = on; }
void set_live_notifications(bool on) noexcept { loaded().liveNotifications = on; }
void set_home_preview(bool on) noexcept { loaded().homePreview = on; }
void set_browse_language(const std::string& code) noexcept {
try {
loaded().browseLanguage = twitch::isBrowseLanguage(code) ? code : std::string();
} catch (...) {
}
}
std::string next_browse_language(const std::string& code) noexcept {
try {
return twitch::nextBrowseLanguage(code);
} catch (...) {
return code;
}
}
std::string prev_browse_language(const std::string& code) noexcept {
try {
return twitch::prevBrowseLanguage(code);
} catch (...) {
return code;
}
}
std::string browse_language_label(const std::string& code) noexcept {
try {
return twitch::browseLanguageLabel(code);
} catch (...) {
return std::string("All languages");
}
}
bool save_settings(const twitch::AppSettings& s) noexcept {
try {
@@ -140,7 +140,7 @@ class NavRail
/* Shared backdrop: the theme's gradient, slow aurora glows and an optional
* picture washed in behind them. */
void draw_backdrop(hui::gfx::DrawList &list, const Context &ctx, std::uint32_t picture = 0,
float picture_alpha = 0.0f);
float picture_alpha = 0.0f, hui::gfx::Rect hole = {});
/* The quality picker (list.rml): one scrolling list. */
class ListScreen
@@ -179,8 +179,18 @@ typedef struct {
int recent_count;
evo_hui_twitch_channel_t recent[EVO_HUI_TWITCH_HOME_RECENT]; /* viewers >= 0 while live */
int recent_col; /* cursor in Recently watched */
int live_picture; /* 1: the hero art is a live picture; leave that rect clear */
const char* streams_language; /* Popular now's language ("German") after its title;
"" or NULL for every language */
} evo_hui_twitch_home_params_t;
/* The hero's art on the 1920x1080 page, where Home's live picture goes
* (Application sets the video rect from these). */
#define EVO_HUI_TWITCH_HERO_X 1024
#define EVO_HUI_TWITCH_HERO_Y 64
#define EVO_HUI_TWITCH_HERO_W 800
#define EVO_HUI_TWITCH_HERO_H 450
void evo_hui_update_twitch_home(const evo_hui_twitch_home_params_t* params);
int evo_hui_render_twitch_home(int width, int height);
@@ -199,6 +209,8 @@ typedef struct {
evo_hui_twitch_category_t categories[EVO_HUI_TWITCH_BROWSE_CATEGORIES];
int cursor; /* the card the cursor is on, kept while the rail is open */
int rail_focused; /* 1 while the rail has the cursor: no ring, the page stays */
const char* language; /* "German streams" after the line under the title when
a stream language is picked; "" or NULL for every one */
} evo_hui_twitch_browse_params_t;
void evo_hui_update_twitch_browse(const evo_hui_twitch_browse_params_t* params);
@@ -237,6 +249,8 @@ typedef struct {
evo_hui_twitch_video_t clips[EVO_HUI_TWITCH_CATEGORY_CLIPS]; /* detail: the channel */
int clip_cursor; /* in the clips, kept while the cursor is elsewhere */
const char* starting_id; /* a clip we asked for is starting, or NULL */
const char* language; /* the live list's: "German streams" after the viewers,
and L1/R1 offered; "" or NULL for every language */
} evo_hui_twitch_category_params_t;
void evo_hui_update_twitch_category(const evo_hui_twitch_category_params_t* params);
@@ -435,6 +449,7 @@ typedef struct {
otherwise */
const char* reply_login; /* shown instead when the fonts can't draw it */
int mention; /* 1: it mentions the logged-in user: on a tint */
int selected; /* 1: the line L1 will answer, drawn with a bar */
} evo_hui_twitch_chat_line_t;
#define EVO_HUI_TWITCH_CHAT_SIDE 0
@@ -463,6 +478,8 @@ typedef struct {
const evo_hui_twitch_chat_line_t* lines; /* oldest first; the newest that fit are drawn */
int line_count;
int text_size; /* EVO_HUI_TWITCH_CHAT_TEXT_* */
int paused; /* 1: a line is picked to reply to and chat holds
still; the side panel's header says so */
} evo_hui_twitch_chat_params_t;
void evo_hui_twitch_chat_update(const evo_hui_twitch_chat_params_t* p);
@@ -129,7 +129,8 @@ std::uint32_t twitch_texture(const Context &ctx, const TwitchPicture &pic);
std::string drawable(const hui::ui::FontRef &font, const std::string &text);
/* text broken into at most max_lines lines no wider than width; the last
* line ends in an ellipsis when the rest does not fit. */
* line ends in an ellipsis when the rest does not fit. No lines at all for
* empty text, so a title drawable() emptied leaves no gap. */
std::vector<std::string> fit_lines(const hui::ui::FontRef &font, const std::string &text, float size, float width,
int max_lines);
@@ -415,7 +416,8 @@ class TwitchHomeScreen
float page_target() const;
/* hero_tex: the hero picture's texture, looked up once per draw. */
void draw_hero(hui::gfx::DrawList &list, const Context &ctx, float alpha, std::uint32_t hero_tex) const;
void draw_hero(hui::gfx::DrawList &list, const Context &ctx, float alpha, std::uint32_t hero_tex,
bool live) const;
void draw_hero_skeleton(hui::gfx::DrawList &list, const Context &ctx, float alpha) const;
void draw_followed(hui::gfx::DrawList &list, const Context &ctx, float alpha) const;
void draw_recent(hui::gfx::DrawList &list, const Context &ctx, float alpha) const;
@@ -429,6 +431,8 @@ class TwitchHomeScreen
std::vector<TwitchStream> streams_;
std::vector<TwitchCategory> categories_;
TwitchPicture hero_art_;
bool live_picture_ = false;
std::string streams_title_ = "Popular now";
int focus_row_ = EVO_HUI_TWITCH_ROW_HERO;
int stream_col_ = 0, category_col_ = 0;
bool rail_focused_ = false;
@@ -492,6 +496,7 @@ class TwitchBrowseScreen
int status_ = EVO_HUI_TWITCH_LOADING;
std::string error_;
std::string language_;
std::vector<TwitchCategory> categories_;
int cursor_ = 0;
bool rail_focused_ = false;
@@ -527,6 +532,7 @@ class TwitchCategoryScreen
std::string name_;
int viewers_ = -1;
std::string language_;
FittedText name_fit_, empty_fit_;
TwitchPicture art_;
CardMotion art_motion_;
@@ -904,6 +910,7 @@ struct ChatLine
std::string name, login, system_text;
std::string reply_to, reply_login; /* a reply's parent; "" otherwise */
bool mention = false; /* drawn on a tint; may change while the line stays */
bool selected = false; /* the line a reply will answer; a bar down its left */
std::uint32_t color = 0xffffff;
std::vector<TwitchPicture> badges;
std::vector<ChatSegment> segments;
@@ -913,7 +920,7 @@ struct ChatLine
/* Chat in the player, drawn over the video: a panel beside it (side mode)
* or a dim column over its right side (overlay mode). Lines never change
* once added, so each is laid out once and kept until it leaves the params.
* No cursor: chat only shows. */
* A selected line (Up/Down in the player) is the one L1 replies to. */
class TwitchChatScreen
{
public:
@@ -931,6 +938,7 @@ class TwitchChatScreen
int text_size_ = EVO_HUI_TWITCH_CHAT_TEXT_MEDIUM;
hui::gfx::Rect video_; /* on the canvas; w == 0 when not known */
std::string channel_;
bool paused_ = false; /* a line is picked to reply to: the header says chat is paused */
FittedText channel_fit_;
std::vector<ChatLine> lines_; /* oldest first, as in the params */
std::vector<ChatLine> merged_; /* set()'s scratch, kept for its capacity */
@@ -242,8 +242,10 @@ const OsdScreen::Lines &OsdScreen::lines(const Context &ctx) const
else
l.watching = viewers_ >= 0 ? format_viewers(viewers_) + " watching" : std::string();
l.vod_title = fit(fonts.display, title_, 44, 1500);
l.vod_meta = fit(fonts.regular, meta_, 22, 1500);
/* Through drawable() like every other line here: a character the font
* lacks would otherwise come out as a box. */
l.vod_title = fit(fonts.display, drawable(fonts.display, title_), 44, 1500);
l.vod_meta = fit(fonts.regular, drawable(fonts.regular, meta_), 22, 1500);
return l;
}
@@ -326,9 +328,16 @@ void OsdScreen::draw_title(DrawList &list, const Context &ctx) const
{
const ui::Fonts &fonts = ctx.fonts;
const Lines &l = lines(ctx);
ui::text(list, fonts.display, l.vod_title, kMargin, 110, 44, kWhite);
/* No title left to draw (all Japanese, say): the line under it moves up
* into its place rather than sitting under a blank one. */
float meta_y = 110;
if (!l.vod_title.empty())
{
ui::text(list, fonts.display, l.vod_title, kMargin, 110, 44, kWhite);
meta_y = 148;
}
if (!l.vod_meta.empty())
ui::text(list, fonts.regular, l.vod_meta, kMargin, 148, 22, kWhite.with_alpha(0.7f));
ui::text(list, fonts.regular, l.vod_meta, kMargin, meta_y, 22, kWhite.with_alpha(0.7f));
}
void OsdScreen::draw_quality(DrawList &list, const Context &ctx) const
@@ -405,6 +414,8 @@ void OsdScreen::draw_bottom(DrawList &list, const Context &ctx) const
hints[n++] = {ui::Button::r1, chat_hint(raw_.chat_mode)};
if (raw_.can_chat)
hints[n++] = {ui::Button::triangle, "Send message"};
if (raw_.can_chat && raw_.chat_mode != EVO_OSD_CHAT_OFF)
hints[n++] = {ui::Button::l1, "Reply"};
if (raw_.can_clip)
hints[n++] = {ui::Button::square, "Clip"};
}
@@ -214,15 +214,45 @@ Palette Palette::from(const evo_rmlui_theme_t &t)
/* ---------------------------------------------------------------- backdrop */
void draw_backdrop(DrawList &list, const Context &ctx, std::uint32_t picture, float picture_alpha)
void draw_backdrop(DrawList &list, const Context &ctx, std::uint32_t picture, float picture_alpha,
Rect hole)
{
const Palette &p = ctx.palette;
const Rect screen{0, 0, ::hui::gfx::kVirtualWidth, ::hui::gfx::kVirtualHeight};
list.gradient_rect(screen, 0, p.bg_top, p.bg_bottom);
const bool cut = hole.w > 1.0f && hole.h > 1.0f;
if (!cut)
list.gradient_rect(screen, 0, p.bg_top, p.bg_bottom);
else
{
/* Four pieces around the hole, so a video quad under the page shows
* through. The gradient keeps the same colours it would have had. */
const float H = screen.h;
const float W = screen.w;
auto col = [&](float y) {
const float t = std::clamp(y / H, 0.0f, 1.0f);
return ::hui::gfx::mix(p.bg_top, p.bg_bottom, t);
};
const float y0 = std::clamp(hole.y, 0.0f, H);
const float y1 = std::clamp(hole.y + hole.h, 0.0f, H);
const float x0 = std::clamp(hole.x, 0.0f, W);
const float x1 = std::clamp(hole.x + hole.w, 0.0f, W);
if (y0 > 0.0f)
list.gradient_rect({0, 0, W, y0}, 0, col(0), col(y0));
if (y1 < H)
list.gradient_rect({0, y1, W, H - y1}, 0, col(y1), col(H));
if (y1 > y0)
{
if (x0 > 0.0f)
list.gradient_rect({0, y0, x0, y1 - y0}, 0, col(y0), col(y1));
if (x1 < W)
list.gradient_rect({x1, y0, W - x1, y1 - y0}, 0, col(y0), col(y1));
}
}
/* The picture, washed in from the right: the left side stays dark for
* the text that sits on it. */
if (picture && picture_alpha > 0.0f)
* the text that sits on it. A hole means that picture would cover the
* video, so it stays off. */
if (!cut && picture && picture_alpha > 0.0f)
{
list.image(picture, screen, ::hui::gfx::kFullUv, kWhite.with_alpha(0.55f * picture_alpha));
list.gradient_rect_h(screen, 0, p.bg_bottom.with_alpha(0.97f), p.bg_bottom.with_alpha(0.35f));
@@ -247,6 +277,14 @@ void draw_backdrop(DrawList &list, const Context &ctx, std::uint32_t picture, fl
const float a = 6.2831853f * t / l.period + l.phase;
const float x = l.x + 120.0f * std::sin(a);
const float y = l.y + 70.0f * std::cos(a * 0.7f);
if (cut)
{
const float nx = std::clamp(x, hole.x, hole.x + hole.w);
const float ny = std::clamp(y, hole.y, hole.y + hole.h);
const float dx = x - nx, dy = y - ny;
if (dx * dx + dy * dy < l.r * l.r)
continue;
}
list.shadow({x - l.r, y - l.r, 2 * l.r, 2 * l.r}, l.r, l.r * 0.9f, l.c);
}
}
@@ -186,6 +186,7 @@ void TwitchBrowseScreen::set(const evo_hui_twitch_browse_params_t &p)
{
status_ = p.status;
error_ = str(p.error);
language_ = str(p.language);
const int n = std::clamp(p.category_count, 0, EVO_HUI_TWITCH_BROWSE_CATEGORIES);
categories_.resize(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i)
@@ -236,7 +237,10 @@ void TwitchBrowseScreen::draw(DrawList &list, const Context &ctx) const
list.push_opacity(in * header_alpha(page));
const float dy = 16.0f * (1.0f - in);
ui::text(list, fonts.display, "Browse", kLeft - 2, kTitleY + dy, 56, p.text);
ui::text(list, fonts.regular, "Top categories right now", kLeft, kLineY + dy, 26, p.text_muted);
ui::text(list, fonts.regular,
language_.empty() ? std::string("Top categories right now")
: "Top categories right now \xC2\xB7 " + language_,
kLeft, kLineY + dy, 26, p.text_muted);
list.pop_opacity();
}
@@ -261,7 +265,7 @@ void TwitchBrowseScreen::draw(DrawList &list, const Context &ctx) const
if (n > 0 || status_ == EVO_HUI_TWITCH_LOADING)
draw_hint_band(list, ctx);
ui::Hint hints[3];
ui::Hint hints[4];
int h = 0;
if (rail_focused_)
h = rail_hints(hints);
@@ -269,6 +273,7 @@ void TwitchBrowseScreen::draw(DrawList &list, const Context &ctx) const
{
if (n > 0)
hints[h++] = {ui::Button::cross, "Open"};
hints[h++] = {ui::Button::l1, "Stream language", ui::Button::r1};
hints[h++] = {ui::Button::triangle, "Refresh"};
hints[h++] = {ui::Button::circle, "Back"};
}
@@ -323,6 +328,7 @@ void TwitchCategoryScreen::set(const evo_hui_twitch_category_params_t &p)
art_motion_.art_in = 0.0f;
name_ = str(p.name);
viewers_ = p.viewers;
language_ = str(p.language);
art_ = twitch_picture(p.art);
art_motion_.track(art_);
@@ -439,8 +445,12 @@ void TwitchCategoryScreen::draw_header(DrawList &list, const Context &ctx, float
ui::text(list, fonts.display, name_fit_.get(fonts.display, name_, 56, kRight - kNameX), kNameX - 2, kTitleY, 56,
p.text);
if (viewers_ >= 0)
ui::text(list, fonts.regular, format_viewers(viewers_) + " watching", kNameX, kLineY, 26, p.text_muted);
/* The viewers are everyone's; a language narrows only the live list. */
std::string line = viewers_ >= 0 ? format_viewers(viewers_) + " watching" : std::string();
if (!language_.empty() && !on_clips())
line += (line.empty() ? "" : " \xC2\xB7 ") + language_;
if (!line.empty())
ui::text(list, fonts.regular, line, kNameX, kLineY, 26, p.text_muted);
list.pop_transform();
list.pop_opacity();
@@ -522,7 +532,7 @@ void TwitchCategoryScreen::draw(DrawList &list, const Context &ctx) const
if (n > 0 || list_status() == EVO_HUI_TWITCH_LOADING)
draw_hint_band(list, ctx);
ui::Hint hints[4];
ui::Hint hints[5];
int h = 0;
if (rail_focused_)
h = rail_hints(hints);
@@ -535,6 +545,8 @@ void TwitchCategoryScreen::draw(DrawList &list, const Context &ctx) const
}
else if (focus_ == EVO_HUI_TWITCH_FOCUS_FILTERS && filter_cursor_ != filter_)
hints[h++] = {ui::Button::cross, "Select"};
if (!on_clips())
hints[h++] = {ui::Button::l1, "Language", ui::Button::r1};
hints[h++] = {ui::Button::triangle, "Refresh"};
hints[h++] = {ui::Button::circle, "Back"};
}
@@ -169,7 +169,9 @@ InfoText info_text(int title_lines, bool has_game, bool has_tags = false)
const float lines = static_cast<float>(std::max(title_lines, 1) - 1);
const float pill_to_title = 29.0f + 58.0f; /* pill, then the title's baseline */
const float title_to_last = kTitleLead * lines;
const float last_to_game = has_game ? 50.0f : 0.0f;
/* No title the fonts can draw (all Japanese, say): the category takes
* its line rather than leaving it blank. */
const float last_to_game = has_game && title_lines > 0 ? 50.0f : 0.0f;
const float game_to_tags = 22.0f;
const float game_to_watch = has_tags ? game_to_tags + kTagH + 30.0f : 44.0f;
const float height = pill_to_title + title_to_last + last_to_game + game_to_watch + kWatchH;
@@ -489,6 +489,13 @@ void draw_line(DrawList &list, const Context &ctx, const ChatLine &line, float x
const Rect tint{x - kMentionPadX, y - pad_y, width + 2.0f * kMentionPadX, l.height + 2.0f * pad_y};
faded_rect(list, tint, kPurple.with_alpha(kMentionAlpha), fade);
}
if (line.selected && l.height > 0.0f)
{
const float pad_y = std::max(0.0f, std::floor(gap * 0.5f) - 1.0f);
const Rect tint{x - kMentionPadX, y - pad_y, width + 2.0f * kMentionPadX, l.height + 2.0f * pad_y};
faded_rect(list, tint, kWhite.with_alpha(0.10f), fade);
faded_rect(list, {x - kMentionPadX, y - pad_y, kBarW, l.height + 2.0f * pad_y}, kWhite, fade);
}
if (l.card)
{
faded_rect(list, {x, y, width, l.height}, kWhite.with_alpha(0.05f), fade);
@@ -599,6 +606,7 @@ void copy_line(ChatLine &out, const evo_hui_twitch_chat_line_t &in)
out.reply_to = str(in.reply_to);
out.reply_login = str(in.reply_login);
out.mention = in.mention != 0;
out.selected = in.selected != 0;
out.color = in.color;
out.badges.clear();
if (in.badges)
@@ -628,6 +636,7 @@ bool refresh_line(ChatLine &line, const evo_hui_twitch_chat_line_t &in)
if (line.kind != in.kind || line.badges.size() != badges || line.segments.size() != segments)
return false;
line.mention = in.mention != 0; /* the login can change while the line stays */
line.selected = in.selected != 0;
for (std::size_t i = 0; i < badges; ++i)
line.badges[i] = twitch_picture(in.badges[i]);
for (std::size_t i = 0; i < segments; ++i)
@@ -647,6 +656,7 @@ void TwitchChatScreen::set(const evo_hui_twitch_chat_params_t &p)
? p.text_size
: EVO_HUI_TWITCH_CHAT_TEXT_MEDIUM;
channel_.assign(p.channel ? p.channel : "");
paused_ = p.paused != 0;
/* The video's rect, kept to the screen (a zoomed picture may start off
* it). Anything not finite, empty or off the screen counts as unknown. */
@@ -726,7 +736,8 @@ void TwitchChatScreen::draw_side(DrawList &list, const Context &ctx) const
const float left = panel.x + m.pad;
const float right = panel.x + panel.w - m.pad;
const float base = std::round((kHeaderH + kCapHeight * kTitleSize) * 0.5f);
const float title_w = ui::text(list, fonts.semibold, "Stream chat", left, base, kTitleSize, kBody);
const float title_w = ui::text(list, fonts.semibold, paused_ ? "Chat paused" : "Stream chat", left, base,
kTitleSize, kBody);
const std::string &channel = channel_fit_.get(fonts.regular, channel_, kChannelSize, right - left - title_w - 24.0f);
ui::text(list, fonts.regular, channel, right, base, kChannelSize, kMuted, Align::right);
list.rounded_rect({panel.x, kHeaderH - 1.0f, panel.w + kBleed, 1.0f}, 0.0f, kDivider);
@@ -182,6 +182,11 @@ std::uint32_t twitch_texture(const Context &ctx, const TwitchPicture &pic)
std::vector<std::string> fit_lines(const ui::FontRef &font, const std::string &text, float size, float width,
int max_lines)
{
/* No text, no lines: wrap() would give one empty line, and a title the
* fonts can't draw at all (drawable() leaves nothing of Japanese or
* Korean) would then hold a blank line open in the layout. */
if (text.empty())
return {};
std::vector<std::string> lines = font.font->wrap(text, size, width);
const std::size_t most = static_cast<std::size_t>(std::max(1, max_lines));
if (lines.size() > most)
@@ -50,7 +50,7 @@ constexpr float kView = tw::kView;
/* The hero is short enough that both rows' titles show over the hint band,
* with the top of the category art under the second one. */
const Rect kHeroArt{1024, 64, 800, 450};
const Rect kHeroArt{EVO_HUI_TWITCH_HERO_X, EVO_HUI_TWITCH_HERO_Y, EVO_HUI_TWITCH_HERO_W, EVO_HUI_TWITCH_HERO_H};
constexpr float kHeroTextW = 680.0f;
/* The text block sits on the picture's bottom edge: Watch ends where the
* picture ends, and the lines stack up from there. */
@@ -110,6 +110,13 @@ HeroText hero_text(int title_lines, bool has_game)
const float last = has_game ? t.game_y - 50.0f : t.game_y;
t.title_y = last - kHeroTitleLead * static_cast<float>(std::max(title_lines, 1) - 1);
t.name_y = t.title_y - 66.0f;
if (title_lines <= 0)
{
/* No title the fonts can draw (all Japanese, say): the name closes
* up onto the category as it would onto the title, or sits a little
* clear of the button (it is much bigger than a title line). */
t.name_y = has_game ? t.game_y - 66.0f : t.game_y - 16.0f;
}
t.pill_y = t.name_y - 106.0f;
return t;
}
@@ -251,6 +258,10 @@ void TwitchHomeScreen::set(const evo_hui_twitch_home_params_t &p)
recent_col_ = std::clamp(p.recent_col, 0, std::max(0, recent_count() - 1));
starting_ = str(p.starting_login);
starting_id_ = str(p.starting_id);
live_picture_ = p.live_picture != 0;
streams_title_ = p.streams_language && p.streams_language[0]
? std::string("Popular now \xC2\xB7 ") + p.streams_language
: std::string("Popular now");
}
void TwitchHomeScreen::enter()
@@ -555,7 +566,8 @@ void TwitchHomeScreen::draw_hero_skeleton(DrawList &list, const Context &ctx, fl
draw_skeleton(list, ctx, kHeroButton, 32, alpha);
}
void TwitchHomeScreen::draw_hero(DrawList &list, const Context &ctx, float alpha, std::uint32_t tex) const
void TwitchHomeScreen::draw_hero(DrawList &list, const Context &ctx, float alpha, std::uint32_t tex,
bool live) const
{
if (streams_.empty() || alpha <= 0.01f)
return;
@@ -564,21 +576,29 @@ void TwitchHomeScreen::draw_hero(DrawList &list, const Context &ctx, float alpha
const TwitchStream &s = streams_[0];
list.push_opacity(alpha);
/* The picture: the large one when it has arrived, the card's until then. */
/* The picture: the large one when it has arrived, the card's until then.
* A live picture is the video quad under this page, so nothing opaque
* goes over that rect. */
const Rect art = kHeroArt;
list.shadow({art.x, art.y + 28, art.w, art.h}, 24, 48, Color::rgb(0x000000, 0.55f));
const TwitchPicture &pic = hero_art_.px ? hero_art_ : s.thumb;
if (hero_motion_.art_in < 1.0f)
draw_skeleton(list, ctx, art, 16);
if (tex)
list.image(tex, art, cover_uv(pic.w, pic.h, art.w, art.h), kWhite.with_alpha(hero_motion_.art_in), 16);
/* The picture's left edge melts into the page, so the text beside it
* reads as part of one block. */
list.gradient_rect_h({art.x, art.y, art.w * 0.45f, art.h}, 16, p.bg_top.with_alpha(0.86f),
p.bg_top.with_alpha(0.0f));
list.gradient_rect({art.x, art.y + art.h * 0.6f, art.w, art.h * 0.4f}, 16, kClear,
Color::rgb(0x000000, 0.32f));
list.bordered_rect(art, 16, kClear, 1.5f, kWhite.with_alpha(0.08f));
if (!live)
{
list.shadow({art.x, art.y + 28, art.w, art.h}, 24, 48, Color::rgb(0x000000, 0.55f));
const TwitchPicture &pic = hero_art_.px ? hero_art_ : s.thumb;
if (hero_motion_.art_in < 1.0f)
draw_skeleton(list, ctx, art, 16);
if (tex)
list.image(tex, art, cover_uv(pic.w, pic.h, art.w, art.h), kWhite.with_alpha(hero_motion_.art_in), 16);
/* The picture's left edge melts into the page, so the text beside it
* reads as part of one block. */
list.gradient_rect_h({art.x, art.y, art.w * 0.45f, art.h}, 16, p.bg_top.with_alpha(0.86f),
p.bg_top.with_alpha(0.0f));
list.gradient_rect({art.x, art.y + art.h * 0.6f, art.w, art.h * 0.4f}, 16, kClear,
Color::rgb(0x000000, 0.32f));
/* A hairline on the still. Over the live picture it would be blended
* again each frame onto a buffer that is not cleared, so the rim
* flickers between two brightnesses. */
list.bordered_rect(art, 16, kClear, 1.5f, kWhite.with_alpha(0.08f));
}
/* Who, what, where. */
const float x = kLeft;
@@ -725,7 +745,7 @@ void TwitchHomeScreen::draw_streams(DrawList &list, const Context &ctx, float al
const Palette &p = ctx.palette;
list.push_opacity(alpha);
const float title_y = kRow1Title + shift();
ui::text(list, fonts.semibold, "Popular now", kLeft, title_y, 30, p.text);
ui::text(list, fonts.semibold, streams_title_, kLeft, title_y, 30, p.text);
const int rn = row_count();
if (!rail_focused_ && focus_row_ == EVO_HUI_TWITCH_ROW_STREAMS && rn > 0)
draw_row_counter(list, ctx, title_y, stream_col_ + 1, rn);
@@ -733,7 +753,7 @@ void TwitchHomeScreen::draw_streams(DrawList &list, const Context &ctx, float al
if (rn == 0 && streams_status_ != EVO_HUI_TWITCH_LOADING)
{
/* The hero has the only stream there is. */
draw_row_note(list, ctx, "Popular now", title_y, "Nobody else is live right now", "to refresh");
draw_row_note(list, ctx, streams_title_.c_str(), title_y, "Nobody else is live right now", "to refresh");
list.pop_opacity();
return;
}
@@ -837,13 +857,17 @@ void TwitchHomeScreen::draw_categories(DrawList &list, const Context &ctx, float
void TwitchHomeScreen::draw(DrawList &list, const Context &ctx) const
{
const float page = page_.value;
/* The video quad is fixed on screen. Only leave the hole while the hero
* sits where that quad is: page at rest, entrance finished. */
const bool live = live_picture_ && std::fabs(page) < 2.0f && tween::clamp01(age_ / 0.6f) > 0.98f;
/* The hero's picture, washed in behind everything (and drawn again as the
* hero itself). */
* hero itself). A live picture takes that rect instead. */
std::uint32_t hero_tex = 0;
if (!streams_.empty())
if (!streams_.empty() && !live)
hero_tex = twitch_texture(ctx, hero_art_.px ? hero_art_ : streams_[0].thumb);
draw_backdrop(list, ctx, hero_tex, 0.45f * hero_motion_.art_in);
const Rect hole = live ? Rect{kHeroArt.x, kHeroArt.y - page, kHeroArt.w, kHeroArt.h} : Rect{};
draw_backdrop(list, ctx, hero_tex, 0.45f * hero_motion_.art_in, hole);
/* A list with nothing in it once it has landed: failed, or nobody live. */
const bool streams_none = streams_.empty() && streams_status_ != EVO_HUI_TWITCH_LOADING;
@@ -882,7 +906,7 @@ void TwitchHomeScreen::draw(DrawList &list, const Context &ctx) const
const float ready = streams_in_.value;
if (ready < 0.99f)
draw_hero_skeleton(list, ctx, in * away * (1.0f - ready));
draw_hero(list, ctx, in * away * ready, hero_tex);
draw_hero(list, ctx, in * away * ready, hero_tex, live);
}
list.pop_transform();
}
+20 -9
View File
@@ -116,16 +116,26 @@ def verdict(host, port):
f.connect(host, port, timeout=10)
f.login()
try:
lines = []
f.cwd("/mnt/usb0")
f.retrlines("LIST", lines.append)
if not any(" evo.log" in l or l.endswith("evo.log") for l in lines):
# The USB stick, or /data/ps5twitch/dev where a dev build started
# with no stick keeps its log and status.
diag = None
for d in ("/mnt/usb0", "/data/ps5twitch/dev"):
lines = []
try:
f.cwd(d)
f.retrlines("LIST", lines.append)
except Exception:
continue
if any(" evo.log" in l or l.endswith("evo.log") for l in lines):
diag = d
break
if diag is None:
return "ABSENT"
def stamp():
buf = []
try:
f.retrbinary("RETR /mnt/usb0/evo_status", buf.append)
f.retrbinary("RETR " + diag + "/evo_status", buf.append)
except Exception:
return None
for tok in b"".join(buf).decode("utf-8", "replace").split():
@@ -260,10 +270,11 @@ with FTP() as ftp:
rmtree(ftp, folder) # kill any stale loose folder for this TID
print(f"cleared {folder} (if present)")
cleared = 0
for name in USB_LOGS:
try: ftp.sendcmd(f"DELE /mnt/usb0/{name}"); cleared += 1
except error_perm: pass
print(f"cleared {cleared} stale /mnt/usb0 log file(s)")
for diag in ("/mnt/usb0", "/data/ps5twitch/dev"):
for name in USB_LOGS:
try: ftp.sendcmd(f"DELE {diag}/{name}"); cleared += 1
except error_perm: pass
print(f"cleared {cleared} stale log file(s) on the stick and in /data/ps5twitch/dev")
for path in (tmp, remote):
try: ftp.sendcmd(f"DELE {path}")
except error_perm: pass
+22 -2
View File
@@ -50,9 +50,11 @@ std::vector<CategoryInfo> parseTopCategories(const nlohmann::json& data);
std::vector<CategoryInfo> topCategories(GqlClient& gql, int first);
// Streams in one category, most viewers first. Asks Twitch for a few extra (same quirk as topStreams).
// Throws NotFoundError when the category doesn't exist.
// Throws NotFoundError when the category doesn't exist. `language` filters to one broadcaster
// language (see kBrowseLanguages); "" and anything not on that list ask for every language.
std::vector<StreamInfo> parseCategoryStreams(const nlohmann::json& data, const std::string& categoryName);
std::vector<StreamInfo> categoryStreams(GqlClient& gql, const std::string& categoryName, int first);
std::vector<StreamInfo> categoryStreams(GqlClient& gql, const std::string& categoryName, int first,
const std::string& language = {});
std::vector<CategoryInfo> parseSearchCategories(const nlohmann::json& data);
std::vector<CategoryInfo> searchCategories(GqlClient& gql, const std::string& query, int first);
@@ -90,6 +92,24 @@ std::string startsInText(const std::string& startAt, long long nowSeconds);
// "zh-hk" -> "Chinese"), any case; "" for a code not in the list ("OTHER" included).
std::string languageName(const std::string& code);
// Browse's language filter. The first entry is "" (every language). The rest are
// Twitch Language enum values, unquoted in a GQL query. "All languages" is the
// label for "". next/prev wrap. A code not in the list is treated as "".
inline constexpr const char* kBrowseLanguages[] = {
"", "EN", "ES", "PT", "DE", "FR", "IT", "NL", "PL",
"RU", "UK", "JA", "KO", "ZH", "TR", "AR", "TH", "SV"};
inline constexpr int kBrowseLanguageCount =
static_cast<int>(sizeof kBrowseLanguages / sizeof kBrowseLanguages[0]);
bool isBrowseLanguage(const std::string& code) noexcept;
// The GQL options clause for a whitelisted code (", options: {broadcasterLanguages: [DE]}"),
// or "" for every language and for a code that is not on the list.
std::string browseLanguageOption(const std::string& code);
std::string nextBrowseLanguage(const std::string& code);
std::string prevBrowseLanguage(const std::string& code);
// "All languages", or languageName() for a code on the list.
std::string browseLanguageLabel(const std::string& code);
// Adds (or, if one is already there, replaces) a `t=<bucket>` query parameter
// on a live preview URL so caches refresh; the bucket is nowSeconds / bucketSeconds,
// so calling this again with the same bucket gives back the same URL
+5 -4
View File
@@ -91,11 +91,12 @@ class HelixClient {
// same pagination as followedStreams. Needs scope user:read:follows.
std::vector<FollowedChannel> followedChannels(const std::string& userId, int max = 300);
// POST /chat/messages, JSON body {broadcaster_id, sender_id, message}.
// Needs scope user:write:chat. The message is sent through nlohmann's own
// JSON string escaping (quotes, control characters).
// POST /chat/messages, JSON body {broadcaster_id, sender_id, message}, plus
// reply_parent_message_id when replyParentId is not empty (Twitch's id for
// the line being answered). Needs scope user:write:chat. The message is
// sent through nlohmann's own JSON string escaping (quotes, control characters).
SendResult sendChat(const std::string& broadcasterId, const std::string& senderId,
const std::string& message);
const std::string& message, const std::string& replyParentId = {});
// POST /clips?broadcaster_id=<id>, no body: a clip of that channel's live
// stream, made by the token's user. Needs scope clips:edit. Twitch answers
+3
View File
@@ -32,6 +32,9 @@ struct AppSettings {
ChatTextSize chatTextSize = ChatTextSize::Medium;
bool thirdPartyEmotes = true; // 7TV, BetterTTV and FrankerFaceZ emotes in chat
bool liveNotifications = true; // a note when a followed channel goes live
bool homePreview = true; // Home's hero plays its stream, muted
// Browse's stream language (kBrowseLanguages). "" is every language.
std::string browseLanguage;
};
// Never throws: a missing, malformed or unknown field keeps its default.
+4 -2
View File
@@ -26,7 +26,9 @@ std::vector<StreamInfo> parseTopStreams(const nlohmann::json& data);
// Live streams in the order Twitch returns them (most viewers first). Returns at
// most `first` streams; asks Twitch for a few extra because it currently returns
// one fewer than requested. Twitch caps a request at 30.
std::vector<StreamInfo> topStreams(GqlClient& gql, int first);
// one fewer than requested. Twitch caps a request at 30. `language` is a
// broadcaster language from browse.hpp ("" for every language); anything else
// is ignored and the list is unfiltered.
std::vector<StreamInfo> topStreams(GqlClient& gql, int first, const std::string& language = {});
} // namespace twitch
+44 -5
View File
@@ -17,9 +17,12 @@ const char* const kTopCategoriesQuery =
"query TopCategories($first: Int!) { games(first: $first) { edges { node { "
"id name displayName viewersCount boxArtURL(width: 285, height: 380) } } } }";
const char* const kCategoryStreamsQuery =
// In two halves, split where a language filter goes (categoryStreams).
const char* const kCategoryStreamsHead =
"query CategoryStreams($name: String!, $first: Int!) { game(name: $name) { "
"name streams(first: $first) { edges { node { id title viewersCount "
"name streams(first: $first";
const char* const kCategoryStreamsTail =
") { edges { node { id title viewersCount "
"previewImageURL(width: 440, height: 248) broadcaster { login displayName } } } } } }";
const char* const kSearchCategoriesQuery =
@@ -187,13 +190,15 @@ std::vector<StreamInfo> parseCategoryStreams(const nlohmann::json& data, const s
}
// Same quirk as topStreams: streams(first: n) returns n-1 edges.
std::vector<StreamInfo> categoryStreams(GqlClient& gql, const std::string& categoryName, int first) {
std::vector<StreamInfo> categoryStreams(GqlClient& gql, const std::string& categoryName, int first,
const std::string& language) {
const int want = clampFirst(first);
if (want <= 0) return {};
const int ask = std::min(want, 25) + 5;
// A language goes in as an enum, never as a variable (see topStreams).
const std::string query = kCategoryStreamsHead + browseLanguageOption(language) + kCategoryStreamsTail;
std::vector<StreamInfo> streams = parseCategoryStreams(
gql.query(kCategoryStreamsQuery, nlohmann::json{{"name", categoryName}, {"first", ask}}),
categoryName);
gql.query(query, nlohmann::json{{"name", categoryName}, {"first", ask}}), categoryName);
if (streams.size() > static_cast<size_t>(want)) streams.resize(static_cast<size_t>(want));
return streams;
}
@@ -353,6 +358,40 @@ std::string startsInText(const std::string& startAt, long long nowSeconds) {
return "In " + std::to_string(days) + (days == 1 ? " day" : " days");
}
bool isBrowseLanguage(const std::string& code) noexcept {
if (code.empty()) return false;
for (int i = 1; i < kBrowseLanguageCount; ++i)
if (code == kBrowseLanguages[i]) return true;
return false;
}
std::string browseLanguageOption(const std::string& code) {
if (!isBrowseLanguage(code)) return "";
return ", options: {broadcasterLanguages: [" + code + "]}";
}
static int browseLanguageIndex(const std::string& code) noexcept {
for (int i = 0; i < kBrowseLanguageCount; ++i)
if (code == kBrowseLanguages[i]) return i;
return 0;
}
std::string nextBrowseLanguage(const std::string& code) {
const int i = browseLanguageIndex(code);
return kBrowseLanguages[(i + 1) % kBrowseLanguageCount];
}
std::string prevBrowseLanguage(const std::string& code) {
const int i = browseLanguageIndex(code);
return kBrowseLanguages[(i + kBrowseLanguageCount - 1) % kBrowseLanguageCount];
}
std::string browseLanguageLabel(const std::string& code) {
if (!isBrowseLanguage(code)) return "All languages";
const std::string name = languageName(code);
return name.empty() ? code : name;
}
std::string languageName(const std::string& code) {
std::string key;
for (const char c : code) {
+8 -6
View File
@@ -267,7 +267,7 @@ std::vector<FollowedChannel> HelixClient::followedChannels(const std::string& us
}
SendResult HelixClient::sendChat(const std::string& broadcasterId, const std::string& senderId,
const std::string& message) {
const std::string& message, const std::string& replyParentId) {
HttpRequest req;
req.method = "POST";
req.url = std::string(kHelixUrl) + "/chat/messages";
@@ -275,11 +275,13 @@ SendResult HelixClient::sendChat(const std::string& broadcasterId, const std::st
{"Client-Id", clientId_},
{"Content-Type", "application/json"}};
// error_handler_t::replace: invalid UTF-8 in console-typed text can never
// throw here (same defensive choice as gql.cpp's query body).
req.body = nlohmann::json{{"broadcaster_id", broadcasterId},
{"sender_id", senderId},
{"message", message}}
.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
// throw here (same defensive choice as gql.cpp's query body). An empty
// reply id is left out: Twitch treats a present empty parent as a bad reply.
nlohmann::json payload{{"broadcaster_id", broadcasterId},
{"sender_id", senderId},
{"message", message}};
if (!replyParentId.empty()) payload["reply_parent_message_id"] = replyParentId;
req.body = payload.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
HttpResponse res = http_.send(req);
checkStatus(res, "send chat");
+3
View File
@@ -77,6 +77,9 @@ AccessToken fetchStreamToken(GqlClient& gql, const std::string& login,
std::string usherStreamUrl(const std::string& login, const AccessToken& token, unsigned nonce) {
return std::string(kUsherStreamBase) + percentEncode(normalizeLogin(login)) + ".m3u8?" +
buildQuery({
// Without it usher leaves out the audio only rendition, and the
// quality menu (and the "audio_only" setting) has nothing to pick.
{"allow_audio_only", "true"},
{"allow_source", "true"},
{"p", std::to_string(nonce)},
{"player_backend", "mediaplayer"},
+9
View File
@@ -2,6 +2,8 @@
#include <nlohmann/json.hpp>
#include "twitch/browse.hpp"
namespace twitch {
namespace {
@@ -68,6 +70,11 @@ AppSettings parseSettings(const std::string& json) noexcept {
}
readBool(j, "third_party_emotes", s.thirdPartyEmotes);
readBool(j, "live_notifications", s.liveNotifications);
readBool(j, "home_preview", s.homePreview);
if (const auto lang = j.find("browse_language"); lang != j.end() && lang->is_string()) {
const std::string v = lang->get<std::string>();
if (v.empty() || isBrowseLanguage(v)) s.browseLanguage = v;
}
} catch (...) {
return AppSettings{};
}
@@ -86,6 +93,8 @@ std::string serializeSettings(const AppSettings& s) {
j["chat_text_size"] = sizeName(s.chatTextSize);
j["third_party_emotes"] = s.thirdPartyEmotes;
j["live_notifications"] = s.liveNotifications;
j["home_preview"] = s.homePreview;
j["browse_language"] = isBrowseLanguage(s.browseLanguage) ? s.browseLanguage : "";
return j.dump() + "\n";
}
+15 -7
View File
@@ -2,15 +2,21 @@
#include <algorithm>
#include "twitch/browse.hpp"
#include "twitch/error.hpp"
#include "json_helpers.hpp"
namespace twitch {
const char* const kTopStreamsQuery =
"query TopStreams($first: Int!) { streams(first: $first) { edges { node { "
"id title viewersCount previewImageURL(width: 440, height: 248) "
"broadcaster { login displayName } game { name } } } } }";
// The query in two halves, split where a language filter goes; the plain
// one is the two together.
#define TOP_STREAMS_HEAD "query TopStreams($first: Int!) { streams(first: $first"
#define TOP_STREAMS_TAIL \
") { edges { node { " \
"id title viewersCount previewImageURL(width: 440, height: 248) " \
"broadcaster { login displayName } game { name } } } } }"
const char* const kTopStreamsQuery = TOP_STREAMS_HEAD TOP_STREAMS_TAIL;
std::vector<StreamInfo> parseTopStreams(const nlohmann::json& data) {
auto streams = data.find("streams");
@@ -41,11 +47,13 @@ std::vector<StreamInfo> parseTopStreams(const nlohmann::json& data) {
// Twitch quirk (seen live, 2026-10): streams(first: n) returns n-1 edges, so
// first: 1 gives none at all. Ask for five more than wanted, capped at 30 (the
// most Twitch accepts), and trim the answer back to `first`.
std::vector<StreamInfo> topStreams(GqlClient& gql, int first) {
std::vector<StreamInfo> topStreams(GqlClient& gql, int first, const std::string& language) {
const int want = first > 0 ? first : 0;
const int ask = std::min(want, 25) + 5; // never want + 5: that overflows near INT_MAX
std::vector<StreamInfo> streams =
parseTopStreams(gql.query(kTopStreamsQuery, nlohmann::json{{"first", ask}}));
// A language goes in as an enum, never as a variable: Twitch rejects a
// string there. With none it is kTopStreamsQuery.
const std::string query = TOP_STREAMS_HEAD + browseLanguageOption(language) + TOP_STREAMS_TAIL;
std::vector<StreamInfo> streams = parseTopStreams(gql.query(query, nlohmann::json{{"first", ask}}));
if (streams.size() > static_cast<size_t>(want)) streams.resize(static_cast<size_t>(want));
return streams;
}
+14
View File
@@ -121,6 +121,18 @@ std::string buildCategoryClipsQuery(ClipPeriod period) {
"curator { displayName } game { name } broadcaster { login displayName } } } } } }";
}
// A broadcast Twitch is still recording has a generic "processing" picture for a thumbnail
// (see the note at the top). The channel's live preview shows that same broadcast, at the size
// the cards draw; for a channel that has gone offline since, the CDN redirects it to its own
// offline picture.
bool isProcessingThumbnail(const std::string& url) {
return url.find("/_404/404_processing") != std::string::npos;
}
std::string livePreviewUrl(const std::string& login) {
return "https://static-cdn.jtvnw.net/previews-ttv/live_user_" + login + "-440x248.jpg";
}
// ---------------------------------------------------------------------------
// Node + connection parsing
// ---------------------------------------------------------------------------
@@ -241,6 +253,8 @@ VideoPage fetchChannelVideos(GqlClient& gql, const std::string& login, VideoType
const nlohmann::json& user = requireUser(data, normalized);
auto [cards, cursor] = parsePagedEdges<VideoCard>(user, "videos", "user.videos", videoCardFromNode);
for (VideoCard& card : cards)
if (isProcessingThumbnail(card.thumbnailUrl)) card.thumbnailUrl = livePreviewUrl(normalized);
return VideoPage{std::move(cards), std::move(cursor)};
}