...
 
Commits (3)
.gitlab/screenshot.png

1.01 MB | W: | H:

.gitlab/screenshot.png

1.1 MB | W: | H:

.gitlab/screenshot.png
.gitlab/screenshot.png
.gitlab/screenshot.png
.gitlab/screenshot.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -9,14 +9,22 @@ their favorites streams._
If you encounter a bug or want to suggest a new feature,
feel free to open an issue.
Right now this client doesn't function well when using an
unstable internet connexion.
## Known issues
- Clicking "Load more" loads streams/games that are
already being displayed when reaching the end of a list. This is an issue
with the API and can't be fixed for now.
- User icon takes some times to disappear when logging off.
- I don't provide binaries yet.
## Requirements
In order to run Tress, you'll need the following requirements:
- Java 11 or higher
To be able to watch streams, you'll need:
- [StreamLink](https://streamlink.github.io/)
- A media player [supported by streamlink](https://streamlink.github.io/players.html#player-compatibility), such as VLC or MPV.
......@@ -31,7 +39,7 @@ cd tress
sbt assembly
```
The `Tress.jar` file is located inside the `target` folder.
`Tress.jar` is located inside `./target`.
#### Windows executable file
......@@ -48,4 +56,5 @@ cd scripts
build
```
The `Tress.exe` file is located inside the `scripts` folder.
\ No newline at end of file
The .exe file (`Tress.exe`) is located inside `./scripts`.
Be aware that the exe still requires Java 11 or higher.
\ No newline at end of file
......@@ -6,13 +6,17 @@ scalaVersion := "2.12.8"
scalacOptions ++= Seq("-Ypartial-unification", "-Ylog-classpath")
val lolhttpVersion = "0.12.0"
val lolhttpVersion = "0.13.0"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5" % "test"
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.7.3"
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.8.1"
libraryDependencies += "com.github.pureconfig" %% "pureconfig" % "0.11.1"
libraryDependencies += "com.lihaoyi" %% "requests" % "0.1.8"
libraryDependencies += "com.lihaoyi" %% "requests" % "0.5.1"
/* LOGGING */
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
libraryDependencies ++= Seq(
"com.criteo.lolhttp" %% "lolhttp",
......
/* https://blog.ngopal.com.np/2012/07/11/customize-scrollbar-via-css/ */
.scroll-bar:horizontal ,
.scroll-bar:vertical {
-fx-background-color:transparent;
}
/* The increment and decrement button CSS class of scrollbar */
.scroll-bar:horizontal .increment-button ,
.scroll-bar:horizontal .decrement-button {
-fx-background-color:transparent;
-fx-background-radius: 0em;
-fx-padding:0 0 12 0;
}
/* The increment and decrement button CSS class of scrollbar */
.scroll-bar:vertical .increment-button ,
.scroll-bar:vertical .decrement-button {
-fx-background-color:transparent;
-fx-background-radius: 0em;
-fx-padding:0 12 0 0;
}
.scroll-bar .increment-arrow,
.scroll-bar .decrement-arrow {
-fx-shape: " ";
-fx-padding:0;
}
/* The main scrollbar **track** CSS class */
.scroll-bar:horizontal .track ,
.scroll-bar:vertical .track {
-fx-background-color: transparent;
-fx-border-color:derive(gray,80%);
-fx-border-radius:2em;
}
/* The main scrollbar **thumb** CSS class which we drag every time (movable) */
.scroll-bar:horizontal .thumb,
.scroll-bar:vertical .thumb {
-fx-background-color:derive(#9147ff,10%);
-fx-background-insets: 3, 0, 0;
-fx-background-radius: 2em;
}
/* ------------------------------------------------------------------------------------- */
/** EVENT CSS **/
/* ------------------------------------------------------------------------------------- */
/* The main scrollbar **track** CSS class on event of "hover" and "pressed" */
.scroll-bar:horizontal:hover .track ,
.scroll-bar:horizontal:pressed .track ,
.scroll-bar:vertical:hover .track,
.scroll-bar:vertical:pressed .track{
-fx-background-color: derive(#434343,20%);
-fx-opacity: 0.2;
-fx-background-radius: 2em;
}
\ No newline at end of file
.root {
-fx-accent: #6441A4;
-fx-focus-color: #6441A4;
-fx-font-family: Arial, Helvetica, "Open Sans";
-fx-accent: #9147ff;
-fx-focus-color: #9147ff;
-fx-font-family: "Cascadia Mono", Arial, Helvetica;
}
.title {
-fx-font-weight: bold;
-fx-font-weight: 600;
-fx-font-size: 1.6em;
-fx-text-fill: #6441A4;
-fx-text-fill: #9147ff;
-fx-label-padding: 5;
}
......@@ -15,68 +15,106 @@
-fx-padding: 10;
}
/* Sidemenu and its buttons */
#side-menu {
-fx-background-color: #6441A4;
-fx-effect: innershadow(three-pass-box, #00000040, 5, 0, -4, 0);
-fx-background-color: #f2f2f2;
-fx-effect: innershadow(three-pass-box, #00000040, 2, 0, 0, 0);
}
.menu-btn {
-fx-alignment: center-left;
-fx-background-color: transparent;
-fx-text-fill: #9147ff;
-fx-font-color: #9147ff;
-fx-padding: 10 10 10 50;
-fx-font-size: 1.1em;
}
.menu-btn > .label {
-fx-text-fill: #9147ff;
}
.menu-btn:hover > .label {
-fx-text-fill: white;
-fx-padding: 7;
}
.menu-btn:hover {
-fx-background-radius: 0px;
-fx-background-color: linear-gradient(
to right,
#00000000 0%,
#3c2762 40%,
#3c2762 80%,
#00000000 100%
);
-fx-text-fill: white;
-fx-font-color: white;
-fx-background-color: #9147ff;
}
.avatar {
-fx-stroke: white;
-fx-stroke-width: 2;
-fx-stroke-type: outside;
}
.active {
-fx-background-radius: 0px;
-fx-text-fill: white;
-fx-font-color: white;
-fx-background-color: #9147ff;
}
.button:hover, .card-img:hover {
-fx-cursor: hand;
.active > .label {
-fx-text-fill: white;
}
/* Drop Shadow for listpanels */
#list-grid {
-fx-background-color: #00000020;
-fx-effect: innershadow(three-pass-box, #00000020, 10, 0, 0, 1);
-fx-background-color: white;
-fx-effect: innershadow(three-pass-box, #00000020, 2, 0, 0, 1);
}
/* Utils */
.white-bg {
-fx-background-color: white;
}
.purple {
-fx-background-color: #6441A4;
-fx-text-fill: white;
-fx-font-color: white;
-fx-border-color: #9147ff;
-fx-border-radius: 5px;
-fx-border-width: 2px;
-fx-background-color: white;
-fx-text-fill: #9147ff;
-fx-font-color: #9147ff;
-fx-background-radius: 3px;
}
.purple-checkbox {
-fx-background-color: white;
}
.purple-checkbox:selected .mark {
-fx-mark-highlight-color: #9147ff;
-fx-mark-color: #9147ff;
}
.purple-text {
-fx-text-fill: #6441A4;
-fx-text-fill: #9147ff;
}
.purple-text-container .label {
-fx-text-fill: #6441A4;
-fx-text-fill: #9147ff;
}
.purple > .label {
-fx-text-fill: white;
-fx-text-fill: #9147ff;
}
#login-label {
-fx-font-weight: bold;
.red {
-fx-text-fill: red !important;
-fx-font-color: red !important;
}
.button:hover, .card-img:hover {
-fx-cursor: hand;
}
.progress-indicator {
-fx-progress-color: #6441A4;
-fx-progress-color: #9147ff;
}
.panel {
-fx-background-color: white;
}
.scroll-pane {
......@@ -84,41 +122,74 @@
}
.pane {
-fx-background-color: #6441A4;
-fx-background-color: #9147ff;
}
.pane .label{
-fx-background-color: #6441A4;
-fx-background-color: #9147ff;
-fx-text-fill: white;
}
#main-panel {
-fx-background-color: white;
-fx-unit-increment: 10;
-fx-block-increment: 5;
}
#login-label {
-fx-text-fill: #9147ff;
-fx-font-weight: bold;
}
/* Dialogs */
.dialog-pane {
-fx-background-color: white;
}
.dialog-pane:header *.header-panel{
-fx-background-color: white;
}
.dialog-pane > .content .text-field {
-fx-border-width: 2px;
-fx-border-color: #9147ff;
-fx-border-radius: 5px;
-fx-focus-color: #9147ff;
-fx-faint-focus-color: #9147ff22;
-fx-background-radius: 5px;
}
.dialog-pane > .button-bar > .container > .button {
-fx-border-color: #9147ff;
-fx-border-radius: 5px;
-fx-border-width: 2px;
-fx-background-color: white;
-fx-text-fill: #9147ff;
-fx-font-color: #9147ff;
-fx-background-radius: 3px;
}
# controlsfx
/* controlsfx */
.popover > .content > .title > .icon > .graphics > .circle {
-fx-fill: white ;
}
.popover .button {
-fx-background-color: #6441A4 !important;
-fx-background-color: #9147ff !important;
-fx-font-color: white !important;
-fx-text-fill: white !important;
-fx-background-radius: 3px !important;
}
.popover .label {
-fx-text-fill: #6441A4;
-fx-text-fill: #9147ff;
-fx-text-alignment: center;
-fx-alignment: top-center;
}
.info-overlay > .info-panel {
-fx-background-color: rgba(100, 65, 164, 0.92) !important;
-fx-background-color: rgba(145, 71, 255, 0.92) !important;
-fx-opacity: 1 !important;
}
......
src/main/resources/images/icon.png

998 Bytes | W: | H:

src/main/resources/images/icon.png

9.71 KB | W: | H:

src/main/resources/images/icon.png
src/main/resources/images/icon.png
src/main/resources/images/icon.png
src/main/resources/images/icon.png
  • 2-up
  • Swipe
  • Onion skin
src/main/resources/images/logo.png

7.19 KB | W: | H:

src/main/resources/images/logo.png

10.9 KB | W: | H:

src/main/resources/images/logo.png
src/main/resources/images/logo.png
src/main/resources/images/logo.png
src/main/resources/images/logo.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -6,7 +6,10 @@
"games-endpoint": "https://api.twitch.tv/helix/games",
"user-details-endpoint": "https://api.twitch.tv/helix/users",
"user-subscriptions-endpoint": "https://api.twitch.tv/helix/users/follows",
"add-follow-endpoint": "https://api.twitch.tv/kraken/users/<user ID>/follows/channels/<channel ID>"
"search-games-endpoint": "https://api.twitch.tv/kraken/search/games?live=true&query=<query>"
"search-streams-endpoint": "https://api.twitch.tv/kraken/search/streams?limit=22&query=<query>"
"oauth-scopes": ["user_follows_edit"],
......
package api
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import api.types.Pagination
import errors.{APIConfigError, APIError}
import util.config.UserConfig
......@@ -22,6 +25,8 @@ object RESTClient {
private val UserDetailsEndpoint: String = APIConfig.getUserDetailsEndpoint
private val UserSubscriptionsEndPoint: String = APIConfig.getUserSubscriptionsEndpoint
private val AddFollowEndPoint: String = APIConfig.getAddFollowEndpoint
private val searchGamesEndPoint: String = APIConfig.getSearchGamesEndPoint
private val searchStreamsEndPoint: String = APIConfig.getSearchStreamsEndPoint
private val defaultMaxItemCount: Int = APIConfig.getDefaultMaxItemCount
......@@ -71,9 +76,11 @@ object RESTClient {
params = params.toList
)
// Should be useless with new version of requests, but just in case ...
if (r.statusCode < 200 || r.statusCode >= 300) {
throw APIError(r.statusCode, r.text)
}
// RETURN VALUES
(r.statusCode, r.text)
}
......@@ -104,6 +111,13 @@ object RESTClient {
sendRequest(StreamsEndPoint, "GET", params, None)
}
def searchStreams(query: String): (Int, String) = {
val params = ListBuffer(("api_version", "5"))
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString)
val url = searchStreamsEndPoint.replace("<query>", encodedQuery)
sendRequest(url, "GET", params, None)
}
def getTopGames(pagination: Option[Pagination]): (Int, String) = {
val params = ListBuffer[(String, String)]()
sendRequest(TopGamesEnPoint, "GET", params, pagination)
......@@ -116,6 +130,13 @@ object RESTClient {
sendRequest(GamesEndPoint, "GET", params, pagination)
}
def searchGames(query: String): (Int, String) = {
val params = ListBuffer(("api_version", "5"))
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString)
val url = searchGamesEndPoint.replace("<query>", encodedQuery)
sendRequest(url, "GET", params, None)
}
def getSubscriptions(
id: String,
pagination: Option[Pagination]
......
......@@ -7,14 +7,12 @@ case class Stream(
user_id: String,
user_name: String,
game_id: String,
community_ids: Option[List[String]],
title: String,
`type`: String,
viewer_count: Int,
started_at: String,
language: String,
thumbnail_url: String,
tag_ids: Option[List[String]],
user_login: Option[String]
) {
def getThumbnail(width: Int, height: Int): String = {
......
......@@ -4,7 +4,8 @@ import java.time._
import api.RESTClient
import api.types.collection.Users
import errors.{APIError, NotLoggedInError}
import errors.NotLoggedInError
import requests.RequestFailedException
import play.api.libs.json.{Json, Reads}
case class User (
......@@ -71,7 +72,9 @@ object User extends APIObject[User] {
lastValidationDate = LocalDateTime.now
cachedUser.get
} catch {
case APIError(401, _) => throw new NotLoggedInError
case e:RequestFailedException =>
if (e.response.statusCode == 401) throw new NotLoggedInError
else throw e
} finally {
locked = false
}
......
......@@ -2,7 +2,8 @@ package api.types.collection
import api.RESTClient
import api.types.{Game, Pagination}
import play.api.libs.json.{Json, Reads}
import errors.APIError
import play.api.libs.json._
class Games(_games: List[Game], _cursor: String) extends APICollection[Game]{
......@@ -17,9 +18,43 @@ object Games extends APICollectionObject[Game] {
protected val readsObject: Reads[Game] = Json.reads[Game]
def topGames(page: Option[Pagination] = None): Games = {
val result = RESTClient.getTopGames(page)
val (games_ , cursor_) = extractFromJson(result._1, result._2)
new Games(games_.toList, cursor_)
}
// Utils for the `search` method
case class Box(template: String)
case class V5Game(_id: Int, name: String, box: Box)
case class Wrapper(games: Option[List[V5Game]])
implicit val readsBox: Reads[Box] = Json.reads[Box]
implicit val readsv5Game: Reads[V5Game] = Json.reads[V5Game]
implicit val readsWrapper: Reads[Wrapper] = Json.reads[Wrapper]
/**
* Send a search query to the API to retrieve a list of games based on the
* query string provided.
*
* @param query The search query string
* @return A `Games` object
*/
def search(query:String): Games = {
val (_, jsonString) = RESTClient.searchGames(query)
Json.fromJson[Wrapper](Json.parse(jsonString)) match {
case JsSuccess(r, _) =>
new Games(
// TODO: put limit in the configuration file ?
_games = r.games.getOrElse(List[V5Game]()).take(20).map(g => new Game(
name = g.name,
id = g._id.toString,
box_art_url = g.box.template
)), _cursor = "")
case e: JsError => throw APIError(422, e.toString)
}
}
}
\ No newline at end of file
......@@ -2,7 +2,8 @@ package api.types.collection
import api.RESTClient
import api.types.{Pagination, Stream}
import play.api.libs.json.{Json, Reads}
import errors.APIError
import play.api.libs.json.{JsError, JsSuccess, Json, Reads}
/** A list of streams objects
*
......@@ -57,4 +58,37 @@ object Streams extends APICollectionObject[Stream] {
val (streams_ , cursor_) = extractFromJson(result._1, result._2)
new Streams(streams_, cursor_)
}
// Utils for the `search` method
case class Channel(_id: Long)
case class V5Stream(channel: Channel)
case class Wrapper(streams: Option[List[V5Stream]])
implicit val readsv5Channel: Reads[Channel] = Json.reads[Channel]
implicit val readsv5Stream: Reads[V5Stream] = Json.reads[V5Stream]
implicit val readsWrapper: Reads[Wrapper] = Json.reads[Wrapper]
/**
* Send a search query to the API to retrieve a list of games based on the
* query string provided.
*
* Since the kraken API does not return the same information as the helix
* API, only the IDs are retrieve and the Streams are retrieved trough the
* helix API.
*
* @param query The search query string
* @return A `Games` object
*/
def search(query:String): Streams = {
val (_, jsonString) = RESTClient.searchStreams(query)
Json.fromJson[Wrapper](Json.parse(jsonString)) match {
case JsSuccess(r, _) =>
val ids = r.streams.getOrElse(List[V5Stream]()).map(
s => s.channel._id.toString
)
topStreams(users = Some(ids))
case e: JsError => throw APIError(422, e.toString)
}
}
}
......@@ -73,7 +73,8 @@ object MainWindow extends PrimaryStage {
scene = new Scene {
stylesheets += getClass.getResource("/css/window.css").toExternalForm
Font.loadFont(getClass.getResource("/font/OpenSans-Regular.ttf").toExternalForm, 10)
stylesheets += getClass.getResource("/css/scrollbar.css").toExternalForm
Font.loadFont(getClass.getResource("/font/custom.ttf").toExternalForm, 10)
root = new GridPane() {
......
......@@ -2,12 +2,37 @@ package gui.panels
import api.types.collection.{Follows, Streams}
import api.types.{Pagination, Stream}
import com.typesafe.scalalogging.LazyLogging
import gui.widgets.StreamCard
import scalafx.application.Platform
import util.config.UserConfig
class FollowsPanel(title:String)
extends ListPanel[Stream](title, false) {
extends ListPanel[Stream](title, false)
with LazyLogging{
// Thread refreshing the list of followed streams every `timeout` minutes
private val thread = new Thread {
override def run(): Unit = {
logger.debug("Starting refresh thread for follows panel")
val timeout = 300000
Thread.sleep(timeout)
while (parent.isNotNull.get()) {
logger.info("Refreshing follow Panel")
Platform.runLater(refresh())
Thread.sleep(timeout)
logger.debug("Checking if follows panel is till visible.")
}
logger.debug("Shutting down refresh thread for follows panel")
}
}
thread.setDaemon(true)
if (UserConfig.getRefreshFollows) thread.start()
def createElement(obj: Stream): StreamCard = {
new StreamCard(obj, true)
......@@ -22,7 +47,7 @@ class FollowsPanel(title:String)
super.initialize()
new Thread {
override def run(): Unit = {
override def run(): Unit = {
val follows = Follows.getSubscriptions().getStreams().withUser()
Platform.runLater(displayObjects(follows))
}
......
......@@ -6,7 +6,14 @@ import gui.widgets.GameCard
import scalafx.application.Platform
class GamesPanel (title: String)
extends ListPanel[Game](title, true, 5) {
extends SearchableListPanel[Game](title, true, 5) {
def searchDialogMsg: String = "Search a specifc game.\n" +
"Search results match with the beginning of the game's name."
def getSearchResult(query: String): Games = {
Games.search(query)
}
def getNextObjects(pageCursor: String): Games = {
Games.topGames(page = Some(Pagination("after", pageCursor)))
......
......@@ -6,17 +6,13 @@ import gui.widgets.jfx2sfx.FAGlyphIcon
import gui.widgets.transitions.FadeIn
import org.controlsfx.glyphfont.FontAwesome
import scalafx.application.Platform
import scalafx.geometry.Pos.{Center, TopCenter}
import scalafx.geometry.Pos.TopCenter
import scalafx.geometry.{Insets, _}
import scalafx.scene.Node
import scalafx.scene.control.{Button, Label, ProgressIndicator, ScrollPane}
import scalafx.scene.layout.Priority.Always
import scalafx.scene.layout.{BorderPane, ColumnConstraints, GridPane}
import scala.concurrent._
import ExecutionContext.Implicits.global
import scala.util.{Failure, Success}
/** An abstract class to display a list of objects retrieved from the twitch API.
*
......@@ -32,7 +28,7 @@ abstract class ListPanel[A](
title: String,
usePagination: Boolean,
cols: Int=3,
previous: Option[Node] = None
previous: Option[Node] = None,
)
extends BorderPane with LoginRequired {
require(cols >= 3)
......@@ -41,6 +37,7 @@ abstract class ListPanel[A](
center = new ProgressIndicator
private val grid = new GridPane() {
styleClass.add("white-bg")
padding = Insets(15,15,15,15)
alignment = TopCenter
hgrow = Always
......@@ -48,7 +45,7 @@ abstract class ListPanel[A](
vgap = 20
}
private val topMenu = new GridPane() {
protected val topMenu: GridPane = new GridPane() {
hgrow = Always
padding = Insets(15,0,15,0)
alignment = TopCenter
......@@ -63,17 +60,23 @@ abstract class ListPanel[A](
content = grid
}
// Shared constraint for the topmenu and the main grid
private val constraint = new ColumnConstraints()
constraint.percentWidth = 100/cols
constraint.halignment = HPos.Center
case class Cell(col: Int, row: Int)
def getNextObjects(pageCursor: String): APICollection[A]
def createElement(obj: A): Node
def refresh(): Unit = initialize()
def initialize(): Unit = {
children.clear()
center = new ProgressIndicator
}
def addObjects(objects: List[A], col: Int, row:Int): Cell = {
protected def addObjects(objects: List[A], col: Int, row:Int): Cell = {
val e = createElement(objects.head)
grid.add(e, col, row)
......@@ -85,20 +88,19 @@ abstract class ListPanel[A](
}
}
def initGrids(): Unit = {
protected def initGrids(): Unit = {
top = topMenu
center = gridContainer
grid.children.clear()
topMenu.children.clear()
val constraint = new ColumnConstraints()
constraint.percentWidth = 100/cols
constraint.halignment = HPos.Center
grid.columnConstraints = List.fill(cols)(constraint)
topMenu.columnConstraints = List.fill(7)(constraint)
}
// Go back button
protected def initTopMenu(): Unit = {
topMenu.children.clear()
topMenu.columnConstraints = List.fill(8)(constraint)
// Go back button
if (previous.isDefined) {
val goBackBtn = new Button("Back") {
graphic =
......@@ -113,7 +115,7 @@ abstract class ListPanel[A](
val refreshBtn = new Button("Refresh") {
graphic = FAGlyphIcon(FontAwesome.Glyph.REFRESH.getChar, size = 14)
styleClass.add("purple")
onMouseClicked = _ => initialize()
onMouseClicked = _ => refresh()
}
// Scroll to Top / To Bottom Buttons
......@@ -135,35 +137,40 @@ abstract class ListPanel[A](
// Add the buttons to the menu
topMenu.add(toTopBtn, 1, 0)
topMenu.add(toBottomBtn, 5, 0)
topMenu.add(refreshBtn, 6, 0)
topMenu.add(refreshBtn, 7, 0)
topMenu.add(new Title(title), 2, 0, 3, 1)
}
def displayObjects(
protected def displayObjects(
objects: APICollection[A],
col: Int = 0,
row: Int = 0,
isUpdate: Boolean = false
isUpdate: Boolean = false,
hideLoadMore: Boolean = false
): Unit = {
// The is a freshly create panel
if(!isUpdate) initGrids()
if(!isUpdate) {
initGrids()
initTopMenu()
}
// There are some things to display
if(objects.objects.isDefinedAt(0)){
val nextCell = addObjects(objects.objects, col, row)
// Display the "load more" btn if pagination is required
if(usePagination) {
// Display the "load more" btn if pagination is required and not
// explicitly disabled with hideLoadMore = true
if(usePagination && !hideLoadMore) {
displayLoadMoreBtn(nextCell, objects.cursor)
}
// There is nothing to display
} else {
if(!isUpdate) {grid.add(Label("Nothing to show"),0,0)}
} else if (!isUpdate) {
grid.add(Label("Sorry, we found nothing to display here ..."),0,0, cols, 1)
}
}
// Display the loadMore button
def displayLoadMoreBtn(nextCell: Cell, currentCursor: String): Unit = {
protected def displayLoadMoreBtn(nextCell: Cell, currentCursor: String): Unit = {
val loadMoreBtn: Button = new Button("Load more") {
styleClass.add("purple")
......
......@@ -16,6 +16,7 @@ object MainPanel extends AnchorPane {
def displayNode(node: Node): Unit = {
children = node
node.styleClass.add("panel")
AnchorPane.setLeftAnchor(node, 0)
AnchorPane.setRightAnchor(node, 0)
AnchorPane.setTopAnchor(node, 0)
......@@ -25,35 +26,43 @@ object MainPanel extends AnchorPane {
def displayGames(): Unit = {
displayNode(new GamesPanel("Top games"))
SideMenu.updateActive("games")
}
def displayStreams(gameID: Option[String], gameName: String, prev: Node): Unit
= {
displayNode(new StreamsPanel(gameName, gameID, Some(prev)))
SideMenu.updateActive("streams")
}
def displayStreams(): Unit = {
displayNode(new StreamsPanel("Top streams"))
SideMenu.updateActive("streams")
}
def displayFollowsPanel(): Unit = {
displayNode(new FollowsPanel("Followed streams"))
SideMenu.updateActive("live")
}
def displayFollowedPanel(): Unit = {
displayNode(new FollowedPanel("Followed users"))
SideMenu.updateActive("followed")
}
def displaySettings(): Unit = {
displayNode(new SettingsPanel)
SideMenu.updateActive("settings")
}
def displayErrorPanel(): Unit = {
displayNode(new ErrorPanel)
SideMenu.updateActive("")
}
def displayLoginPanel(callback: () => Unit): Unit = {
displayNode(new LoginPanel(callback))
SideMenu.updateActive("")
}
}
package gui.panels
import api.types.collection.APICollection
import gui.MainWindow
import gui.widgets.jfx2sfx.FAGlyphIcon
import org.controlsfx.glyphfont.FontAwesome
import scalafx.application.Platform
import scalafx.scene.Node
import scalafx.scene.control.{Button, ProgressIndicator, TextInputDialog}
import scalafx.scene.image.ImageView
/**
* Abstract class to enable searches on a list panel
*
* @param title The title of the Panel.
* @param usePagination Whether the list is displayed using pagination or not.
* If set to true, next objects are displayed when
* pressing the "loadMore" button.
* @param cols How many columns are used to display the list.
* @param previous The previous panel from which the user came from. When
* provided, a "goBack" button is added to the panel.
* @tparam A An `APIObject`.
*/
abstract class SearchableListPanel[A](
title: String,
usePagination: Boolean,
cols: Int=3,
previous: Option[Node] = None,
)
extends ListPanel[A](title, usePagination, cols, previous) {
/**
* Method to retrieve the message to display in the search dialog.
*
* @return The message to display
*/
def searchDialogMsg: String
/**
* Method to retrieve the search results
* @param query A query string
* @return An `APICollection` object
*/
def getSearchResult(query: String): APICollection[A]
/**
* Retrieve and display the search results
* @param query A query string
*/
private def getAndDisplaySearchResult(query: String): Unit = {
center = new ProgressIndicator()
new Thread() {
override def run(): Unit = {
val objects = getSearchResult(query)
Platform.runLater(displayObjects(objects, hideLoadMore = true))
}
}.start()
}
// The button to display the search dialog
val searchButton: Button = new Button("Search") {
graphic = FAGlyphIcon(FontAwesome.Glyph.SEARCH.getChar, size = 14)
styleClass.add("purple")
// The search dialog
val dialog: TextInputDialog = new TextInputDialog() {
graphic = new ImageView("images/search.png")
initOwner(MainWindow)
title = "Search"
headerText = searchDialogMsg
contentText = "Your search query: "
}
onMouseClicked = _ => {
dialog.showAndWait() match {
case Some(query) => if (query != "") getAndDisplaySearchResult(query)
case _ =>
}
}
}
// Add the additional button to the topmenu
override def initTopMenu(): Unit = {
super.initTopMenu()
topMenu.add(searchButton, 6, 0)
}
}
package gui.panels
import gui.widgets.control.{ChoiceSelector, FileDialogButton, Title}
import gui.widgets.control.{ChoiceSelector, FileDialogButton, SimpleCheckBox, Title}
import scalafx.collections.ObservableBuffer
import scalafx.geometry.Insets
import scalafx.scene.control.Label
......@@ -51,4 +51,12 @@ class SettingsPanel extends GridPane {
UserConfig.setQuality),
1,3)
}
// Autorefresh Follows
add(new Label("Auto refresh online followed streams :"), 0, 4)
add(
new SimpleCheckBox(
UserConfig.getRefreshFollows,
UserConfig.setRefreshFollows),
1,4)
}
package gui.panels
import gui.widgets.{MenuButton, UserAvatar}
import api.types.User
import errors.NotLoggedInError
import gui.widgets.UserAvatar
import gui.widgets.control.MenuButton
import scalafx.application.Platform
import scalafx.geometry.Insets
import scalafx.geometry.Pos.Center
......@@ -11,13 +14,31 @@ import scalafx.scene.layout.{AnchorPane, Region, VBox}
object SideMenu extends VBox{
id = "side-menu"
padding = Insets(25)
padding = Insets(20,0,20,0)
spacing = 5
minWidth = 100
private val userImg = new UserAvatar{radius <== width / 6}
def updateAvatar(): Unit = {userImg.setUserAvatar()}
def updateAvatar(): Unit = {
val thread = new Thread {
override def run(): Unit = {
try {
val user = User.authenticatedUser
userImg.update(user.profile_image_url, user.display_name)
Platform.runLater(children.add(userImg))
} catch {
case _: NotLoggedInError =>
Platform.runLater(children.remove(userImg))
case _: Throwable =>
Thread.sleep(3000)
updateAvatar()
}
}
}
thread.setDaemon(true)
thread.start()
}
private val logo = new AnchorPane(){
id = "twitch-logo"
......@@ -30,16 +51,33 @@ object SideMenu extends VBox{
}
}
val btnMap: Map[String, MenuButton] = Map(
"games" -> new MenuButton("Games", width, MainPanel.displayGames),
"streams" -> new MenuButton("Streams", width, MainPanel.displayStreams),
"live" -> new MenuButton("Live", width, MainPanel.displayFollowsPanel),
"followed" -> new MenuButton("Followed", width, MainPanel.displayFollowedPanel),
"settings" -> new MenuButton("Settings", width, MainPanel.displaySettings),
)
/* Update the currently active button */
def updateActive(btnKey: String): Unit = {
btnMap.values.foreach(btn => btn.styleClass.remove("active"))
btnMap.get(btnKey) match {
case Some(btn) => btn.styleClass.add("active")
case None =>
}
}
children_=(List(
logo,
new MenuButton("Games", width, MainPanel.displayGames),
new MenuButton("Streams", width, MainPanel.displayStreams),
new MenuButton("Live", width, MainPanel.displayFollowsPanel),
new MenuButton("Followed", width, MainPanel.displayFollowedPanel),
new MenuButton("Settings", width, MainPanel.displaySettings),
new Region(){vgrow = Always},
userImg
btnMap("games"),
btnMap("streams"),
btnMap("live"),
btnMap("followed"),
btnMap("settings"),
new Region(){vgrow = Always}
))
Platform.runLater(userImg.setUserAvatar())
Platform.runLater(updateAvatar())
}
package gui.panels
import api.types.{Pagination, Stream}
import api.types.collection.Streams
import api.types.{Pagination, Stream}
import gui.widgets.StreamCard
import scalafx.application.Platform
import scalafx.scene.Node
......@@ -11,12 +11,19 @@ class StreamsPanel(
gameID: Option[String] = None,
previous: Option[Node] = None
)
extends ListPanel[Stream](title, true, previous = previous) {
extends SearchableListPanel[Stream](title, true, previous=previous) {
def searchDialogMsg: String = "Search a specifc game.\n" +
"Limited to the first 21 results."
def createElement(obj: Stream): StreamCard = {
new StreamCard(obj, false)
}
def getSearchResult(query: String): Streams = {
Streams.search(query).withUser()
}
def getNextObjects(pageCursor: String): Streams = {
Streams.topStreams(
page = Some(Pagination("after", pageCursor)),
......@@ -26,7 +33,6 @@ class StreamsPanel(
override def initialize(): Unit = {
super.initialize()
new Thread {
override def run(): Unit = {
val streams = Streams.topStreams(gameID = gameID).withUser()
......
package gui.widgets
import api.types.User
import errors.NotLoggedInError
import javafx.scene.input.MouseButton
import javafx.scene.paint.ImagePattern
import org.controlsfx.control.PopOver
import scalafx.scene.image.Image
import scalafx.scene.shape.Circle
......@@ -13,28 +10,15 @@ class UserAvatar extends Circle {
styleClass.add("avatar")
val defaultImg = "images/twitch_icon.png"
private val userMenu = new UserMenu()
def setUserAvatar(): Unit = {
new Thread{
override def run(): Unit = {
try {
val user = User.authenticatedUser
setAvatar(user.profile_image_url)
} catch {
case _:NotLoggedInError => setAvatar(defaultImg)
case e:Throwable => throw e
}
}
}.start()
}
def setAvatar(url: String): Unit = {
def update(url: String, username: String): Unit = {
this.setFill(new ImagePattern(new Image(url)))
userMenu.updateLabel(username)
}
private val userMenu: PopOver = new UserMenu()
onMouseClicked = event => {
println(this.fill.value)
event.getButton match {
case MouseButton.PRIMARY | MouseButton.SECONDARY => userMenu.show(this)
case _ =>
......
package gui.widgets
import api.types.User
import errors.NotLoggedInError
import gui.panels.{MainPanel, SideMenu}
import org.controlsfx.control.PopOver
import scalafx.geometry.Insets
import scalafx.scene.control.Label
import scalafx.scene.control.{Button, Label}
import scalafx.scene.layout.Priority.Always
import scalafx.scene.layout.VBox
import util.LoginManager
......@@ -23,28 +20,27 @@ class UserMenu extends PopOver(){
setContentNode(content)
setDetachable(false)
val logoffBtn = new MenuButton("Logout", content.width, () => {
LoginManager.logoff()
hide()
})
val loginBtn = new MenuButton("Login", content.width, () => {
LoginManager.startOAuth(() => {
MainPanel.displayFollowsPanel()
SideMenu.updateAvatar()
})
hide()
})
setOnShowing(_ => {
content.children = try {
val user = User.authenticatedUser
List(
new Label(user.login.capitalize){prefWidth <== content.width},
logoffBtn
)
} catch {
case _:NotLoggedInError => List(loginBtn)
private val nameLabel = new Label() {
prefWidth <== content.width
}
private val logoffBtn = new Button("Logout") {
prefWidth <== content.width
onMouseClicked = _ => {
LoginManager.logoff()
hide()
}
})
}
setOnShowing(_ =>
content.children = List(
nameLabel,
logoffBtn
)
)
def updateLabel(username: String): Unit = {
nameLabel.setText(username)
}
}
\ No newline at end of file
package gui.widgets
package gui.widgets.control
import scalafx.Includes._
import gui.widgets.jfx2sfx.FAGlyphIcon
import org.controlsfx.glyphfont.FontAwesome
import scalafx.beans.property.ReadOnlyDoubleProperty
import scalafx.scene.control.Button
import scalafx.scene.layout.VBox
class MenuButton(
label: String,
width: ReadOnlyDoubleProperty,
action: () => Unit
action: () => Unit,
) extends Button(label) {
prefWidth <== width
styleClass.add("menu-btn")
onMouseClicked = _ => action()
graphic = FAGlyphIcon(FontAwesome.Glyph.CHEVRON_RIGHT.getChar, size = 10)
}
package gui.widgets.control
import gui.MainWindow
import scalafx.scene.control.CheckBox
class SimpleCheckBox(checked: Boolean, callback: Boolean => Unit)
extends CheckBox {
styleClass.add("purple-checkbox")
selected = checked
onAction = _ => {
callback(selected.value)
MainWindow.displayNotification("Settings saved")
}
}
......@@ -19,6 +19,8 @@ object APIConfig {
userSubscriptionsEndpoint: String,
userDetailsEndpoint: String,
addFollowEndpoint: String,
searchGamesEndpoint: String,
searchStreamsEndpoint: String,
defaultItemCount: Int,
maxIDCount: Int
)
......@@ -42,6 +44,8 @@ object APIConfig {
def getUserDetailsEndpoint: String = conf.api.userDetailsEndpoint
def getUserSubscriptionsEndpoint: String = conf.api.userSubscriptionsEndpoint
def getAddFollowEndpoint: String = conf.api.addFollowEndpoint
def getSearchGamesEndPoint: String = conf.api.searchGamesEndpoint
def getSearchStreamsEndPoint: String = conf.api.searchStreamsEndpoint
def getDefaultMaxItemCount: Int = conf.api.defaultItemCount
def getmaxIDCount: Int = conf.api.maxIDCount
......
......@@ -16,7 +16,8 @@ object UserConfig {
quality: Option[String] = None,
height: Option[Double] = None,
width: Option[Double] = None,
maximized: Option[Boolean] = None
maximized: Option[Boolean] = None,
refreshFollows: Boolean = true
)
private val file = "user.conf"
......@@ -59,6 +60,10 @@ object UserConfig {
conf.maximized
}
def getRefreshFollows: Boolean = {
conf.refreshFollows
}
def setToken(token: String): Unit = {
conf = conf.copy(token = Some(token))
saveConf()
......@@ -94,6 +99,11 @@ object UserConfig {
saveConf()
}
def setRefreshFollows(refresh: Boolean): Unit = {
conf = conf.copy(refreshFollows = refresh)
saveConf()
}
private def saveConf(): Unit = {
val writer = ConfigWriter[Config].to(conf)
val out = new PrintWriter(file)
......
......@@ -84,14 +84,12 @@ class StreamsTest extends FunSuite with PrivateMethodTester {
assert(streams.head.user_id === user_id)
assert(streams.head.user_name === user_name)
assert(streams.head.game_id === game_id)
assert(streams.head.community_ids.get.length === 3)
assert(streams.head.`type` === `type`)
assert(streams.head.title === title)
assert(streams.head.viewer_count === viewer_count)
assert(streams.head.started_at === started_at)
assert(streams.head.language === language)
assert(streams.head.thumbnail_url === thumbnail_url)
assert(streams.head.tag_ids.get.length === 2)
assert(cursor === cursor_string)
}
......