Allow showing stories in different contexts.

This commit is contained in:
John Preston 2023-06-05 16:10:34 +04:00
parent e7c0385aea
commit b71d72ca7c
21 changed files with 231 additions and 112 deletions

View File

@ -95,7 +95,9 @@ object_ptr<Ui::BoxContent> PrepareContactsBox(
raw->clicks(
) | rpl::start_with_next([=](uint64 id) {
sessionController->openPeerStories(PeerId(int64(id)), {});
sessionController->openPeerStories(
PeerId(int64(id)),
Data::StorySourcesList::All);
}, raw->lifetime());
raw->showProfileRequests(

View File

@ -744,7 +744,13 @@ void Stories::resolve(FullStoryId id, Fn<void()> done) {
}
}
void Stories::loadAround(FullStoryId id) {
void Stories::loadAround(FullStoryId id, StoriesContext context) {
if (v::is<StoriesContextSingle>(context.data)) {
return;
} else if (v::is<StoriesContextSaved>(context.data)
|| v::is<StoriesContextArchive>(context.data)) {
return;
}
const auto i = _all.find(id.peer);
if (i == end(_all)) {
return;

View File

@ -139,6 +139,32 @@ enum class StorySourcesList : uchar {
All,
};
struct StoriesContextSingle {
};
struct StoriesContextPeer {
};
struct StoriesContextSaved {
};
struct StoriesContextArchive {
};
struct StoriesContext {
std::variant<
StoriesContextSingle,
StoriesContextPeer,
StoriesContextSaved,
StoriesContextArchive,
StorySourcesList> data;
friend inline auto operator<=>(
StoriesContext,
StoriesContext) = default;
friend inline bool operator==(StoriesContext, StoriesContext) = default;
};
inline constexpr auto kStorySourcesListCount = 2;
class Stories final {
@ -159,7 +185,7 @@ public:
void loadMore(StorySourcesList list);
void apply(const MTPDupdateStory &data);
void loadAround(FullStoryId id);
void loadAround(FullStoryId id, StoriesContext context);
[[nodiscard]] const base::flat_map<PeerId, StoriesSource> &all() const;
[[nodiscard]] const std::vector<StoriesSourceInfo> &sources(

View File

@ -127,7 +127,7 @@ State::State(not_null<Data::Stories*> data, Data::StorySourcesList list)
}
Content State::next() {
auto result = Content();
auto result = Content{ .full = (_list == Data::StorySourcesList::All) };
const auto &all = _data->all();
const auto &sources = _data->sources(_list);
result.users.reserve(sources.size());

View File

@ -265,7 +265,8 @@ List::Layout List::computeLayout() const {
+ st::defaultDialogRow.photoSize
+ st::defaultDialogRow.padding.left();
const auto narrow = (width() <= narrowWidth);
const auto smallWidth = st.photo + (itemsCount - 1) * st.shift;
const auto smallCount = std::min(kSmallUserpicsShown, itemsCount);
const auto smallWidth = st.photo + (smallCount - 1) * st.shift;
const auto leftSmall = narrow
? ((narrowWidth - smallWidth) / 2 - st.photoLeft)
: st.left;
@ -278,7 +279,7 @@ List::Layout List::computeLayout() const {
(width() - leftFull + singleFull - 1) / singleFull,
itemsCount);
const auto startIndexSmall = 0;
const auto endIndexSmall = std::min(kSmallUserpicsShown, itemsCount);
const auto endIndexSmall = smallCount;
const auto cellLeftSmall = leftSmall;
const auto userpicLeftFull = cellLeftFull + full.photoLeft;
const auto userpicLeftSmall = cellLeftSmall + st.photoLeft;
@ -714,10 +715,12 @@ void List::contextMenuEvent(QContextMenuEvent *e) {
_menu->addAction(tr::lng_context_view_profile(tr::now), [=] {
_showProfileRequests.fire_copy(id);
});
_menu->addAction(hidden
? tr::lng_stories_show_in_chats(tr::now)
: tr::lng_stories_hide_to_contacts(tr::now),
[=] { _toggleShown.fire({ .id = id, .shown = hidden }); });
if (!_content.full || hidden) {
_menu->addAction(hidden
? tr::lng_stories_show_in_chats(tr::now)
: tr::lng_stories_hide_to_contacts(tr::now),
[=] { _toggleShown.fire({ .id = id, .shown = hidden }); });
}
const auto updateAfterMenuDestroyed = [=] {
const auto globalPosition = QCursor::pos();
if (rect().contains(mapFromGlobal(globalPosition))) {

View File

@ -37,6 +37,7 @@ struct User {
struct Content {
std::vector<User> users;
bool full = false;
friend inline bool operator==(
const Content &a,

View File

@ -230,6 +230,7 @@ FormatPointer MakeFormatPointer(
if (!io) {
return {};
}
io->seekable = (seek != nullptr);
auto result = avformat_alloc_context();
if (!result) {
LogError(u"avformat_alloc_context"_q);
@ -250,7 +251,9 @@ FormatPointer MakeFormatPointer(
LogError(u"avformat_open_input"_q, error);
return {};
}
result->flags |= AVFMT_FLAG_FAST_SEEK;
if (seek) {
result->flags |= AVFMT_FLAG_FAST_SEEK;
}
// Now FormatPointer will own and free the IO context.
io.release();

View File

@ -387,29 +387,54 @@ auto Controller::stickerOrEmojiChosen() const
void Controller::show(
not_null<Data::Story*> story,
Data::StorySourcesList list) {
Data::StoriesContext context) {
using namespace Data;
auto &stories = story->owner().stories();
const auto &all = stories.all();
const auto &sources = stories.sources(list);
const auto storyId = story->fullId();
const auto id = storyId.story;
const auto i = ranges::find(
sources,
storyId.peer,
&Data::StoriesSourceInfo::id);
if (i == end(sources)) {
const auto &all = stories.all();
const auto inAll = all.find(storyId.peer);
auto source = (inAll != end(all)) ? &inAll->second : nullptr;
auto single = StoriesSource{ story->peer()->asUser() };
v::match(context.data, [&](StoriesContextSingle) {
source = &single;
hideSiblings();
}, [&](StoriesContextPeer) {
hideSiblings();
}, [&](StoriesContextSaved) {
hideSiblings();
}, [&](StoriesContextArchive) {
hideSiblings();
}, [&](StorySourcesList list) {
const auto &sources = stories.sources(list);
const auto i = ranges::find(
sources,
storyId.peer,
&StoriesSourceInfo::id);
if (i == end(sources)) {
source = nullptr;
return;
}
showSiblings(&story->session(), sources, (i - begin(sources)));
if (int(sources.end() - i) < kPreloadUsersCount) {
stories.loadMore(list);
}
});
const auto idDate = story->idDate();
if (!source) {
return;
} else if (source == &single) {
single.ids.emplace(idDate);
_index = 0;
} else {
const auto k = source->ids.find(idDate);
if (k == end(source->ids)) {
return;
}
_index = (k - begin(source->ids));
}
const auto j = all.find(storyId.peer);
if (j == end(all)) {
return;
}
const auto &source = j->second;
const auto k = source.ids.lower_bound(Data::StoryIdDate{ id });
if (k == end(source.ids) || k->id != id) {
return;
}
showSiblings(&story->session(), sources, (i - begin(sources)));
const auto guard = gsl::finally([&] {
_paused = false;
_started = false;
@ -419,12 +444,11 @@ void Controller::show(
_photoPlayback = nullptr;
}
});
if (_source != source) {
_source = source;
if (_source != *source) {
_source = *source;
}
_index = (k - begin(source.ids));
_context = context;
_waitingForId = {};
if (_shown == storyId) {
return;
}
@ -436,13 +460,13 @@ void Controller::show(
unfocusReply();
}
_header->show({ .user = source.user, .date = story->date() });
_slider->show({ .index = _index, .total = int(source.ids.size()) });
_replyArea->show({ .user = source.user, .id = id });
_header->show({ .user = source->user, .date = story->date() });
_slider->show({ .index = _index, .total = int(source->ids.size()) });
_replyArea->show({ .user = source->user, .id = id });
_recentViews->show({
.list = story->recentViewers(),
.total = story->views(),
.valid = source.user->isSelf(),
.valid = source->user->isSelf(),
});
const auto session = &story->session();
@ -463,13 +487,10 @@ void Controller::show(
}, _sessionLifetime);
}
if (int(sources.end() - i) < kPreloadUsersCount) {
stories.loadMore(list);
}
stories.loadAround(storyId);
stories.loadAround(storyId, context);
updatePlayingAllowed();
source.user->updateFull();
source->user->updateFull();
}
void Controller::updatePlayingAllowed() {
@ -509,6 +530,11 @@ void Controller::showSiblings(
(index + 1 < sources.size()) ? sources[index + 1].id : PeerId());
}
void Controller::hideSiblings() {
_siblingLeft = nullptr;
_siblingRight = nullptr;
}
void Controller::showSibling(
std::unique_ptr<Sibling> &sibling,
not_null<Main::Session*> session,
@ -616,10 +642,10 @@ void Controller::subjumpTo(int index) {
};
auto &stories = _source->user->owner().stories();
if (stories.lookup(id)) {
_delegate->storiesJumpTo(&_source->user->session(), id);
_delegate->storiesJumpTo(&_source->user->session(), id, _context);
} else if (_waitingForId != id) {
_waitingForId = id;
stories.loadAround(id);
stories.loadAround(id, _context);
}
}
@ -637,7 +663,8 @@ void Controller::checkWaitingFor() {
}
_delegate->storiesJumpTo(
&_source->user->session(),
base::take(_waitingForId));
base::take(_waitingForId),
_context);
}
bool Controller::jumpFor(int delta) {
@ -645,7 +672,8 @@ bool Controller::jumpFor(int delta) {
if (const auto left = _siblingLeft.get()) {
_delegate->storiesJumpTo(
&left->peer()->session(),
left->shownId());
left->shownId(),
_context);
return true;
}
} else if (delta == 1) {
@ -655,7 +683,8 @@ bool Controller::jumpFor(int delta) {
if (const auto right = _siblingRight.get()) {
_delegate->storiesJumpTo(
&right->peer()->session(),
right->shownId());
right->shownId(),
_context);
return true;
}
}

View File

@ -99,7 +99,7 @@ public:
[[nodiscard]] auto stickerOrEmojiChosen() const
-> rpl::producer<ChatHelpers::FileChosen>;
void show(not_null<Data::Story*> story, Data::StorySourcesList list);
void show(not_null<Data::Story*> story, Data::StoriesContext context);
void ready();
void updateVideoPlayback(const Player::TrackState &state);
@ -137,6 +137,7 @@ private:
void updatePlayingAllowed();
void setPlayingAllowed(bool allowed);
void hideSiblings();
void showSiblings(
not_null<Main::Session*> session,
const std::vector<Data::StoriesSourceInfo> &lists,
@ -178,6 +179,7 @@ private:
FullStoryId _shown;
TextWithEntities _captionText;
Data::StoriesContext _context;
std::optional<Data::StoriesSource> _source;
FullStoryId _waitingForId;
int _index = 0;

View File

@ -12,6 +12,10 @@ class Show;
struct FileChosen;
} // namespace ChatHelpers
namespace Data {
struct StoriesContext;
} // namespace Data
namespace Main {
class Session;
} // namespace Main
@ -41,7 +45,8 @@ public:
-> rpl::producer<ChatHelpers::FileChosen> = 0;
virtual void storiesJumpTo(
not_null<Main::Session*> session,
FullStoryId id) = 0;
FullStoryId id,
Data::StoriesContext context) = 0;
virtual void storiesClose() = 0;
[[nodiscard]] virtual bool storiesPaused() = 0;
[[nodiscard]] virtual rpl::producer<bool> storiesLayerShown() = 0;

View File

@ -22,8 +22,10 @@ View::View(not_null<Delegate*> delegate)
View::~View() = default;
void View::show(not_null<Data::Story*> story, Data::StorySourcesList list) {
_controller->show(story, list);
void View::show(
not_null<Data::Story*> story,
Data::StoriesContext context) {
_controller->show(story, context);
}
void View::ready() {

View File

@ -9,7 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
namespace Data {
class Story;
enum class StorySourcesList : uchar;
struct StoriesContext;
struct FileOrigin;
} // namespace Data
@ -53,7 +53,7 @@ public:
explicit View(not_null<Delegate*> delegate);
~View();
void show(not_null<Data::Story*> story, Data::StorySourcesList list);
void show(not_null<Data::Story*> story, Data::StoriesContext context);
void ready();
[[nodiscard]] bool canDownload() const;

View File

@ -40,11 +40,13 @@ enum class Mode {
struct PlaybackOptions {
Mode mode = Mode::Both;
crl::time position = 0;
crl::time durationOverride = 0;
float64 speed = 1.; // Valid values between 0.5 and 2.
AudioMsgId audioId;
bool syncVideoByAudio = true;
bool waitForMarkAsShown = false;
bool hwAllowed = false;
bool seekable = true;
bool loop = false;
};

View File

@ -150,7 +150,7 @@ Stream File::Context::initStream(
not_null<AVFormatContext*> format,
AVMediaType type,
Mode mode,
bool hwAllowed) {
StartOptions options) {
auto result = Stream();
const auto index = result.index = av_find_best_stream(
format,
@ -171,7 +171,7 @@ Stream File::Context::initStream(
}
result.codec = FFmpeg::MakeCodecPointer({
.stream = info,
.hwAllowed = hwAllowed,
.hwAllowed = options.hwAllow,
});
if (!result.codec) {
return result;
@ -196,7 +196,9 @@ Stream File::Context::initStream(
return result;
}
result.timeBase = info->time_base;
result.duration = (info->duration != AV_NOPTS_VALUE)
result.duration = options.durationOverride
? options.durationOverride
: (info->duration != AV_NOPTS_VALUE)
? FFmpeg::PtsToTime(info->duration, result.timeBase)
: UnreliableFormatDuration(format, info, mode)
? kTimeUnknown
@ -269,17 +271,19 @@ std::variant<FFmpeg::Packet, FFmpeg::AvErrorWrap> File::Context::readPacket() {
return error;
}
void File::Context::start(crl::time position, bool hwAllow) {
void File::Context::start(StartOptions options) {
Expects(options.seekable || !options.position);
auto error = FFmpeg::AvErrorWrap();
if (unroll()) {
return;
}
auto format = FFmpeg::MakeFormatPointer(
static_cast<void *>(this),
static_cast<void*>(this),
&Context::Read,
nullptr,
&Context::Seek);
options.seekable ? &Context::Seek : nullptr);
if (!format) {
return fail(Error::OpenFailed);
}
@ -289,12 +293,20 @@ void File::Context::start(crl::time position, bool hwAllow) {
}
const auto mode = _delegate->fileOpenMode();
auto video = initStream(format.get(), AVMEDIA_TYPE_VIDEO, mode, hwAllow);
auto video = initStream(
format.get(),
AVMEDIA_TYPE_VIDEO,
mode,
options);
if (unroll()) {
return;
}
auto audio = initStream(format.get(), AVMEDIA_TYPE_AUDIO, mode, false);
auto audio = initStream(
format.get(),
AVMEDIA_TYPE_AUDIO,
mode,
options);
if (unroll()) {
return;
}
@ -303,8 +315,11 @@ void File::Context::start(crl::time position, bool hwAllow) {
if (_reader->isRemoteLoader()) {
sendFullInCache(true);
}
if (video.codec || audio.codec) {
seekToPosition(format.get(), video.codec ? video : audio, position);
if (options.seekable && (video.codec || audio.codec)) {
seekToPosition(
format.get(),
video.codec ? video : audio,
options.position);
}
if (unroll()) {
return;
@ -434,10 +449,7 @@ File::File(std::shared_ptr<Reader> reader)
: _reader(std::move(reader)) {
}
void File::start(
not_null<FileDelegate*> delegate,
crl::time position,
bool hwAllow) {
void File::start(not_null<FileDelegate*> delegate, StartOptions options) {
stop(true);
_reader->startStreaming();
@ -445,7 +457,7 @@ void File::start(
_thread = std::thread([=, context = &*_context] {
crl::toggle_fp_exceptions(true);
context->start(position, hwAllow);
context->start(options);
while (!context->finished()) {
context->readNextPacket();
}

View File

@ -21,6 +21,13 @@ namespace Streaming {
class FileDelegate;
struct StartOptions {
crl::time position = 0;
crl::time durationOverride = 0;
bool seekable = true;
bool hwAllow = false;
};
class File final {
public:
explicit File(std::shared_ptr<Reader> reader);
@ -28,10 +35,7 @@ public:
File(const File &other) = delete;
File &operator=(const File &other) = delete;
void start(
not_null<FileDelegate*> delegate,
crl::time position,
bool hwAllow);
void start(not_null<FileDelegate*> delegate, StartOptions options);
void wake();
void stop(bool stillActive = false);
@ -46,7 +50,7 @@ private:
Context(not_null<FileDelegate*> delegate, not_null<Reader*> reader);
~Context();
void start(crl::time position, bool hwAllow);
void start(StartOptions options);
void readNextPacket();
void interrupt();
@ -79,7 +83,7 @@ private:
not_null<AVFormatContext *> format,
AVMediaType type,
Mode mode,
bool hwAllowed);
StartOptions options);
void seekToPosition(
not_null<AVFormatContext *> format,
const Stream &stream,

View File

@ -544,8 +544,16 @@ void Player::play(const PlaybackOptions &options) {
if (!Media::Audio::SupportsSpeedControl()) {
_options.speed = 1.;
}
if (!_options.seekable) {
_options.position = 0;
}
_stage = Stage::Initializing;
_file->start(delegate(), _options.position, _options.hwAllowed);
_file->start(delegate(), {
.position = _options.position,
.durationOverride = options.durationOverride,
.seekable = _options.seekable,
.hwAllow = _options.hwAllowed,
});
}
void Player::savePreviousReceivedTill(

View File

@ -8,17 +8,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#pragma once
#include "data/data_cloud_themes.h"
#include "data/data_stories.h"
class DocumentData;
class PeerData;
class PhotoData;
class HistoryItem;
namespace Data {
class Story;
enum class StorySourcesList : uchar;
} // namespace Data
namespace Window {
class SessionController;
} // namespace Window
@ -75,14 +71,10 @@ public:
OpenRequest(
Window::SessionController *controller,
not_null<Data::Story*> story,
Data::StorySourcesList list,
bool continueStreaming = false,
crl::time startTime = 0)
Data::StoriesContext context)
: _controller(controller)
, _story(story)
, _storiesList(list)
, _continueStreaming(continueStreaming)
, _startTime(startTime) {
, _storiesContext(context) {
}
[[nodiscard]] PeerData *peer() const {
@ -108,8 +100,8 @@ public:
[[nodiscard]] Data::Story *story() const {
return _story;
}
[[nodiscard]] Data::StorySourcesList storiesList() const {
return _storiesList;
[[nodiscard]] Data::StoriesContext storiesContext() const {
return _storiesContext;
}
[[nodiscard]] std::optional<Data::CloudTheme> cloudTheme() const {
@ -133,7 +125,7 @@ private:
DocumentData *_document = nullptr;
PhotoData *_photo = nullptr;
Data::Story *_story = nullptr;
Data::StorySourcesList _storiesList = {};
Data::StoriesContext _storiesContext;
PeerData *_peer = nullptr;
HistoryItem *_item = nullptr;
MsgId _topicRootId = 0;

View File

@ -287,6 +287,17 @@ struct OverlayWidget::PipWrap {
rpl::lifetime lifetime;
};
struct OverlayWidget::ItemContext {
not_null<HistoryItem*> item;
MsgId topicRootId = 0;
};
struct OverlayWidget::StoriesContext {
not_null<PeerData*> peer;
StoryId id = 0;
Data::StoriesContext within;
};
class OverlayWidget::Show final : public ChatHelpers::Show {
public:
explicit Show(not_null<OverlayWidget*> widget) : _widget(widget) {
@ -3064,7 +3075,7 @@ void OverlayWidget::show(OpenRequest request) {
setContext(StoriesContext{
story->peer(),
story->id(),
request.storiesList(),
request.storiesContext(),
});
} else if (contextPeer) {
setContext(contextPeer);
@ -3085,7 +3096,11 @@ void OverlayWidget::show(OpenRequest request) {
setSession(&document->session());
if (story) {
setContext(StoriesContext{ story->peer(), story->id() });
setContext(StoriesContext{
story->peer(),
story->id(),
request.storiesContext(),
});
} else if (contextItem) {
setContext(ItemContext{ contextItem, contextTopicRootId });
} else {
@ -3868,9 +3883,16 @@ void OverlayWidget::restartAtSeekPosition(crl::time position) {
_rotation = saved;
updateContentRect();
}
auto options = Streaming::PlaybackOptions();
options.position = position;
options.hwAllowed = Core::App().settings().hardwareAcceleratedVideo();
auto options = Streaming::PlaybackOptions{
.position = position,
.durationOverride = ((_stories
&& _document
&& _document->getDuration() > 0)
? (_document->getDuration() * crl::time(1000) + crl::time(999))
: crl::time(0)),
.hwAllowed = Core::App().settings().hardwareAcceleratedVideo(),
.seekable = !_stories,
};
if (!_streamed->withSound) {
options.mode = Streaming::Mode::Video;
options.loop = true;
@ -4025,7 +4047,8 @@ auto OverlayWidget::storiesStickerOrEmojiChosen()
void OverlayWidget::storiesJumpTo(
not_null<Main::Session*> session,
FullStoryId id) {
FullStoryId id,
Data::StoriesContext context) {
Expects(_stories != nullptr);
Expects(id.valid());
@ -4035,7 +4058,11 @@ void OverlayWidget::storiesJumpTo(
return;
}
const auto story = *maybeStory;
setContext(StoriesContext{ story->peer(), story->id() });
setContext(StoriesContext{
story->peer(),
story->id(),
context,
});
clearStreaming();
_streamingStartPaused = false;
v::match(story->media().data, [&](not_null<PhotoData*> photo) {
@ -4982,7 +5009,7 @@ void OverlayWidget::setContext(
const auto maybeStory = stories.lookup(
{ story->peer->id, story->id });
if (maybeStory) {
_stories->show(*maybeStory, story->list);
_stories->show(*maybeStory, story->within);
}
} else {
_message = nullptr;

View File

@ -30,7 +30,7 @@ enum class activation : uchar;
namespace Data {
class PhotoMedia;
class DocumentMedia;
enum class StorySourcesList : uchar;
struct StoriesContext;
} // namespace Data
namespace Ui {
@ -134,6 +134,8 @@ private:
class Show;
struct Streamed;
struct PipWrap;
struct ItemContext;
struct StoriesContext;
class Renderer;
class RendererSW;
class RendererGL;
@ -245,7 +247,8 @@ private:
-> rpl::producer<ChatHelpers::FileChosen> override;
void storiesJumpTo(
not_null<Main::Session*> session,
FullStoryId id) override;
FullStoryId id,
Data::StoriesContext context) override;
void storiesClose() override;
bool storiesPaused() override;
rpl::producer<bool> storiesLayerShown() override;
@ -300,15 +303,6 @@ private:
Entity entityForItemId(const FullMsgId &itemId) const;
bool moveToEntity(const Entity &entity, int preloadDelta = 0);
struct ItemContext {
not_null<HistoryItem*> item;
MsgId topicRootId = 0;
};
struct StoriesContext {
not_null<PeerData*> peer;
StoryId id = 0;
Data::StorySourcesList list = {};
};
void setContext(std::variant<
v::null_t,
ItemContext,

View File

@ -2467,13 +2467,13 @@ Ui::ChatThemeBackgroundData SessionController::backgroundData(
void SessionController::openPeerStory(
not_null<PeerData*> peer,
StoryId storyId,
Data::StorySourcesList list) {
Data::StoriesContext context) {
using namespace Media::View;
using namespace Data;
auto &stories = session().data().stories();
if (const auto from = stories.lookup({ peer->id, storyId })) {
window().openInMediaView(OpenRequest(this, *from, list));
window().openInMediaView(OpenRequest(this, *from, context));
}
}
@ -2492,7 +2492,7 @@ void SessionController::openPeerStories(
openPeerStory(
i->second.user,
j != i->second.ids.end() ? j->id : i->second.ids.front().id,
list);
{ list });
}
}

View File

@ -30,6 +30,7 @@ enum class WindowLayout;
} // namespace Adaptive
namespace Data {
struct StoriesContext;
enum class StorySourcesList : uchar;
} // namespace Data
@ -571,7 +572,7 @@ public:
void openPeerStory(
not_null<PeerData*> peer,
StoryId storyId,
Data::StorySourcesList list);
Data::StoriesContext context);
void openPeerStories(PeerId peerId, Data::StorySourcesList list);
struct PaintContextArgs {