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

985 KB | W: | H:

.gitlab/screenshot.png

1.01 MB | W: | H:

.gitlab/screenshot.png
.gitlab/screenshot.png
.gitlab/screenshot.png
.gitlab/screenshot.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -17,5 +17,6 @@
"oauth-response-type": "token",
"default-item-count": 15
"max-id-count": 100
}
}
\ No newline at end of file
......@@ -37,6 +37,5 @@ object Tress extends JFXApp {
)
})
}
Platform.runLater(MainPanel.displayErrorPanel())
}
}
package api
import api.types.Pagination
import errors.{APIConfigError, APIError}
import util.config.UserConfig
import util.config.APIConfig
import scala.collection.mutable.ListBuffer
import pureconfig.generic.auto._
import util.config.APIConfig
object RESTClient {
case class APIConfig (
clientID: String,
streamsEndpoint: String,
topGamesEndpoint: String,
gamesEndpoint: String,
userSubscriptionsEndpoint: String,
userDetailsEndpoint: String,
addFollowEndpoint: String,
oauthValidateURL: String,
oauthRevokeURL: String,
defaultItemCount: Int
)
case class Config (api: APIConfig)
private val conf: Config = pureconfig.loadConfig[Config] match {
case Right(c) => c
case Left(e) => throw new APIConfigError(e.toString)
}
private var oauthToken = UserConfig.getToken.getOrElse("")
private val oauthValidateURL = conf.api.oauthValidateURL
private val oauthRevokeURL = conf.api.oauthRevokeURL
private val clientID = conf.api.clientID
private val oauthValidateURL: String = APIConfig.getOauthValidateURL
private val oauthRevokeURL: String = APIConfig.getOauthRevokeURL
private val clientID: String = APIConfig.getClientID
private val StreamsEndPoint = conf.api.streamsEndpoint
private val TopGamesEnPoint = conf.api.topGamesEndpoint
private val GamesEndPoint = conf.api.gamesEndpoint
private val UserDetailsEndpoint = conf.api.userDetailsEndpoint
private val UserSubscriptionsEndPoint = conf.api.userSubscriptionsEndpoint
private val AddFollowEndPoint = conf.api.addFollowEndpoint
private val StreamsEndPoint: String = APIConfig.getStreamsEndPoint
private val TopGamesEnPoint: String = APIConfig.getTopGamesEnPoint
private val GamesEndPoint: String = APIConfig.getGamesEndPoint
private val UserDetailsEndpoint: String = APIConfig.getUserDetailsEndpoint
private val UserSubscriptionsEndPoint: String = APIConfig.getUserSubscriptionsEndpoint
private val AddFollowEndPoint: String = APIConfig.getAddFollowEndpoint
private val defaultMaxItemCount = conf.api.defaultItemCount
private val defaultMaxItemCount: Int = APIConfig.getDefaultMaxItemCount
def setToken(token: String): Unit = {
oauthToken = token
......@@ -65,7 +44,8 @@ object RESTClient {
params: ListBuffer[(String, String)],
pagination: Option[Pagination],
auth: String = "Bearer",
maxLength: Int = defaultMaxItemCount
maxLength: Int = defaultMaxItemCount,
retry: Int = 5
): (Int, String) = {
pagination match {
......@@ -83,16 +63,27 @@ object RESTClient {
case _ => throw new APIConfigError(f"Invalid method: $method")
}
val r = requestMethod(
endPoint,
headers = defaultHeader(auth),
params = params.toList
)
if ( r.statusCode < 200 || r.statusCode >= 300) {
throw new APIError(r.statusCode, r.text)
// Send the request and retry if the request failed and retry != 0
try {
val r = requestMethod(
endPoint,
headers = defaultHeader(auth),
params = params.toList
)
if (r.statusCode < 200 || r.statusCode >= 300) {
throw new APIError(r.statusCode, r.text)
}
// RETURN VALUES
(r.statusCode, r.text)
}
catch {
case e:Throwable =>
if (retry > 0) {
Thread.sleep(500)
sendRequest(endPoint,method,params,pagination,auth,maxLength,retry-1)
} else throw e
}
(r.statusCode, r.text)
}
def getTopStreams(
......@@ -134,10 +125,12 @@ object RESTClient {
sendRequest(UserSubscriptionsEndPoint, "GET", params, pagination)
}
def getUsersDetails(ids: Option[List[String]]): (Int, String) = {
def getUsersDetails(
ids: Option[List[String]],
limit: Int = defaultMaxItemCount): (Int, String) = {
val params = ListBuffer[(String, String)]()
if(ids.isDefined) ids.get.foreach(u => params += (("id", u)))
sendRequest(UserDetailsEndpoint, "GET", params, None)
sendRequest(UserDetailsEndpoint, "GET", params, None, maxLength = limit)
}
def editFollow(userID: String, channelID: String, delete: Boolean): Unit = {
......
......@@ -29,7 +29,7 @@ case class Stream(
cachedValue.get
} else {
val game = Game.getByID(this.game_id)
Stream.idsCache(game.id) = game.name
Stream.idsCache(game_id) = game.name
game.name
}
}
......
......@@ -10,9 +10,17 @@ import play.api.libs.json.{Json, Reads}
case class User (
login: String,
id: String,
display_name: String,
profile_image_url: String
)
{
def getThumbnail(width: Int, height: Int): String = {
this.profile_image_url
.replace("{width}", width.toString)
.replace("{height}", height.toString)
}
def follow(channelID: String): Unit = {
RESTClient.editFollow(id, channelID, delete = false)
}
......
......@@ -3,6 +3,8 @@ package api.types.collection
import api.RESTClient
import api.types.{Follow, Pagination, User}
import play.api.libs.json.{Json, Reads}
import util.config.APIConfig
import util.config.UserConfig.Config
class Follows(_follows: List[Follow], _cursor: String)
extends APICollection[Follow]{
......@@ -10,18 +12,34 @@ class Follows(_follows: List[Follow], _cursor: String)
def objects: List[Follow] = _follows
def cursor: String = _cursor
private val TWITCH_MAX_ID_PER_REQUEST = APIConfig.getmaxIDCount
override def toString: String = _follows.map(follow => follow.to_name)
.mkString(start=" - ", sep="\n - ", end="" )
/*
* Get streams associated to the follow objects
*/
def getStreams(page: Option[Pagination] = None): Streams = {
Streams.topStreams(page = page, users = Some(objects.map(f => f.to_id)))
}
/*
* Get users associated to the follow objects
*/
def getUsers(
page: Option[Pagination] = None,
users: List[User] = List(),
follows: List[Follow] = _follows): Users = {
Users.getUsers(ids = objects.map(f => f.to_id))
}
}
object Follows extends APICollectionObject[Follow] {
protected val readsObject: Reads[Follow] = Json.reads[Follow]
@scala.annotation.tailrec
def getSubscriptions(
follows: List[Follow] = List(),
page: Option[Pagination] = None): Follows = {
......
package gui.panels
import api.types.collection.{Follows, Streams, Users}
import api.types.{Pagination, User}
import gui.widgets.{StreamCard, UserCard}
import scalafx.application.Platform
class FollowedPanel(title:String)
extends ListPanel[User](title, false, cols = 5) {
def createElement(obj: User): UserCard = {
new UserCard(obj)
}
def getNextObjects(pageCursor: String): Users = {
val follows = Follows.getSubscriptions()
follows.getUsers(Some(Pagination("after", pageCursor)))
}
override def initialize(): Unit = {
super.initialize()
new Thread {
override def run(): Unit = {
val follows = Follows.getSubscriptions()
Platform.runLater(displayObjects(follows.getUsers()))
}
}.start()
}
}
......@@ -7,7 +7,7 @@ import scalafx.application.Platform
class FollowsPanel(title:String)
extends ListPanel[Stream](title, true) {
extends ListPanel[Stream](title, false) {
def createElement(obj: Stream): StreamCard = {
new StreamCard(obj, true)
......
......@@ -17,6 +17,17 @@ 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.
*
* @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.
* */
abstract class ListPanel[A](
title: String,
usePagination: Boolean,
......@@ -85,7 +96,7 @@ abstract class ListPanel[A](
constraint.setPercentWidth(100/cols)
constraint.halignment = HPos.Center
grid.columnConstraints = List.fill(cols)(constraint)
topMenu.columnConstraints = List.fill(5)(constraint)
topMenu.columnConstraints = List.fill(7)(constraint)
// Go back button
if (previous.isDefined) {
......@@ -121,10 +132,11 @@ abstract class ListPanel[A](
onMouseClicked = _ => gridContainer.setVvalue(1.0d)
}
// Add the buttons to the menu
topMenu.add(toTopBtn, 1, 0)
topMenu.add(toBottomBtn, 3, 0)
topMenu.add(refreshBtn, 4, 0)
topMenu.add(new Title(title), 2, 0, 1, 1)
topMenu.add(toBottomBtn, 5, 0)
topMenu.add(refreshBtn, 6, 0)
topMenu.add(new Title(title), 2, 0, 3, 1)
}
def displayObjects(
......@@ -140,15 +152,9 @@ abstract class ListPanel[A](
// There are some things to display
if(objects.objects.isDefinedAt(0)){
val nextCell = addObjects(objects.objects, col, row)
// Preload the following objects and display the "load more" btn if needed
// Display the "load more" btn if pagination is required
if(usePagination) {
new Thread {
override def run(): Unit = {
val nextObjects = getNextObjects(objects.cursor)
if (nextObjects.objects.isDefinedAt(0))
Platform.runLater(displayLoadMoreBtn(nextObjects, nextCell))
}
}.start()
displayLoadMoreBtn(nextCell, objects.cursor)
}
// There is nothing to display
} else {
......@@ -157,30 +163,53 @@ abstract class ListPanel[A](
}
// Display the loadMore button
def displayLoadMoreBtn(
nextObjects: APICollection[A],
nextCell: Cell): Unit = {
def displayLoadMoreBtn(nextCell: Cell, currentCursor: String): Unit = {
val loadMoreBtn: Button = new Button("Load more") {
styleClass.add("purple")
// When clicking the button, display loading animation and start
// retrieving the next objects
onMouseClicked = _ => {
// The loading animation
val loadMoreProgress: ProgressIndicator = new ProgressIndicator() {
val transition = new FadeIn(300, this)
transition.play()
transition.onFinished = _ => {
displayObjects(
nextObjects,
nextCell.col,
nextCell.row,
isUpdate = true
)
grid.children.remove(this)
}
}
// Remove the button and add the loading animation
grid.children.remove(this)
grid.add(loadMoreProgress, 0, nextCell.row + 1, cols, 4)
// Retrieve the next objects and display them
new Thread() {
override def run(): Unit = {
val nextObjects = try {
getNextObjects(currentCursor)
} catch {
// If retrieving the items fails remove the loading animation,
// display the button again and throw the error
case e:Throwable =>
Platform.runLater({
grid.children.remove(loadMoreProgress)
displayLoadMoreBtn(nextCell, currentCursor)
})
throw e
}
//Display the items and remove the loading animation
Platform.runLater({
displayObjects(
nextObjects,
nextCell.col,
nextCell.row,
isUpdate = true)
grid.children.remove(loadMoreProgress)
})
}
}.start()
}
}
// Add the button to the panel
grid.add(loadMoreBtn, 0, nextCell.row + 1, cols, 1)
}
}
......@@ -43,6 +43,10 @@ object MainPanel extends AnchorPane {
displayNode(new FollowsPanel("Followed streams"))
}
def displayFollowedPanel(): Unit = {
displayNode(new FollowedPanel("Followed users"))
}
def displaySettings(): Unit = {
displayNode(new SettingsPanel)
}
......
......@@ -34,7 +34,8 @@ object SideMenu extends VBox{
logo,
new MenuButton("Games", width, MainPanel.displayGames),
new MenuButton("Streams", width, MainPanel.displayStreams),
new MenuButton("Following", width, MainPanel.displayFollowsPanel),
new MenuButton("Live", width, MainPanel.displayFollowsPanel),
new MenuButton("Followed", width, MainPanel.displayFollowedPanel),
new MenuButton("Settings", width, MainPanel.displaySettings),
new Region(){vgrow = Always},
userImg
......
......@@ -6,6 +6,7 @@ import gui.MainWindow
import gui.panels.MainPanel
import gui.widgets.alert.ErrorAlert
import gui.widgets.jfx2sfx.{FAGlyphIcon, Overlay}
import gui.widgets.popover.StreamPopover
import gui.widgets.transitions.FadeIn
import javafx.scene.input.MouseButton
import org.controlsfx.control.PopOver
......
package gui.widgets
import api.types.User
import gui.panels.MainPanel
import gui.widgets.popover.UserPopover
import gui.widgets.transitions.FadeIn
import javafx.scene.input.MouseButton
import scalafx.geometry.Pos.Center
import scalafx.scene.Node
import scalafx.scene.control.{Label, ProgressIndicator}
import scalafx.scene.image.{Image, ImageView}
import scalafx.scene.layout.Priority.Always
import scalafx.scene.layout.VBox
import util.Browser
class UserCard(user: User) extends VBox {
minWidth = 50
hgrow = Always
alignment = Center
private val image = new Image(user.getThumbnail(285, 380), true)
private val thumbnail =
new ImageView(image) {
fitWidth <== width
preserveRatio = true
styleClass.add("card-img")
}
private val imgProgress = new ProgressIndicator()
private val gameName = new Label(user.display_name){styleClass.add("purple-text")}
children = List(
imgProgress,
thumbnail,
gameName
)
private val transition = new FadeIn(600, this)
transition.play()
// Context menu
private val contextMenu = new UserPopover(user, true, image)
image.progress.onChange(
(progress,_,_) => if(progress() == 1.0){children.remove(imgProgress)}
)
onMousePressed = e => {
e.getButton match {
case MouseButton.PRIMARY => {
val url = f"https://www.twitch.tv/${user.login}"
Browser.openURL(url)
}
case MouseButton.SECONDARY => {
contextMenu.show(this)
}
case _ =>
}
}
}
package gui.widgets
package gui.widgets.popover
import api.types.{Stream, User}
import gui.MainWindow
import gui.panels.MainPanel
import org.controlsfx.control.PopOver
import scalafx.application.Platform
import scalafx.geometry.Insets
import scalafx.geometry.Pos.Center
import scalafx.scene.control.{Button, Label, ProgressIndicator}
import scalafx.scene.Node
import scalafx.scene.control.Button
import scalafx.scene.image.{Image, ImageView}
import scalafx.scene.layout.Priority.Always
import scalafx.scene.layout.VBox
import util.Browser
class StreamPopover(stream: Stream, followed: Boolean, image: Image)
extends PopOver {
/** Abstract class for building context menu popping up when right clicking
* on a gui element related to a channel (a stream or a user for instance)
*
* This class provides elements common to any channel popover menu but it's up
* to the implementation to choose what to display by constructing the
* childrenList
*
* @constructor Created a context menu for a stream GUI element
* @param followed whether the user is currently following the streamer or not
* @param image a thumbnail used when displaying a notification
* */
private val content: VBox = new VBox(){
abstract class ChannelPopover(followed: Boolean, image: Image) extends PopOver {
// Values required for the menu items provided
val userLogin: String
val userID: String
val userName: String
val childrenList: List[Node]
def display(): Unit
// Container for the menu's content
protected val content: VBox = new VBox(){
minWidth = 150
margin = Insets(10)
spacing = 7
......@@ -24,77 +45,51 @@ class StreamPopover(stream: Stream, followed: Boolean, image: Image)
alignment = Center
}
// Configuration of the popover
setArrowLocation(PopOver.ArrowLocation.TOP_CENTER)
setContentNode(content)
setDetachable(false)
private val gameNameSpinner = new ProgressIndicator() {
prefWidth <== content.width / 10d
prefHeight <== prefWidth
}
private val channelBtn = new Button {
// Open the channel's page in a browser
protected val channelBtn: Button = new Button {
text = "Channel Page"
prefWidth <== content.width
onAction = _ => {
val url = f"https://www.twitch.tv/${stream.user_login.get}"
Browser.openURL(url)
}
}
private val chatBtn = new Button {
text = "Chat"
prefWidth <== content.width
onAction = _ => {
val url = f"https://www.twitch.tv/popout/${stream.user_login.get}/chat"
val url = f"https://www.twitch.tv/${userLogin}"
Browser.openURL(url)
}
}
private val followBtn = new Button{
// Follow or unfollow the streamer
protected val followBtn: Button = new Button{
text = if (followed) "Unfollow" else "Follow"
prefWidth <== content.width
onAction = _ => {
if (followed) {
User.authenticatedUser.unfollow(stream.user_id)
MainPanel.displayFollowsPanel()
val msg: String = if (followed) {
User.authenticatedUser.unfollow(userID)
MainPanel.displayFollowedPanel()
f"You are not following ${userName} anymore."
}
else {
User.authenticatedUser.follow(stream.user_id)
MainWindow.displayNotification(
f"You are now following ${stream.user_name}",
new ImageView(image){
fitWidth = 50
preserveRatio = true
}
)
User.authenticatedUser.follow(userID)
f"You are now following ${userName}."
}
hide()
MainWindow.displayNotification(
msg,
new ImageView(image){
fitWidth = 50
preserveRatio = true
}
)
hide() // Hide this menu
}
}
// When displayed, call the implementation of display()
setOnShowing(_ => {
content.children = List(
new Label(stream.user_name){prefWidth <== content.width},
gameNameSpinner,
channelBtn,
chatBtn,
followBtn
)
// Get game name
new Thread() {
override def run(): Unit = {
val gameName = stream.getGameName
Platform.runLater({
content.children.add(1, new Label(gameName))
content.children.remove(gameNameSpinner)
})
}
}.start()
content.children = childrenList
display()
})
}
package gui.widgets.popover
import api.types.Stream
import errors.APIError
import scalafx.application.Platform
import scalafx.scene.control.{Button, Label, ProgressIndicator}
import scalafx.scene.image.Image
import util.Browser
/** The context menu popping up when right clicking on a specific stream
*
* The game name is loaded with an async task because twitch's API only returns
* the ID of the game.
*
* @constructor Created a context menu for a stream GUI element
* @param stream the stream object to witch the menu is attached to
* @param followed whether the user is currently following the streamer or not
* @param image the thumbnail of the stream used when displaying a notification
* */
class StreamPopover(stream: Stream, followed: Boolean, image: Image)
extends ChannelPopover(followed, image) {
val userLogin: String = stream.user_login.get
val userID: String = stream.user_id
val userName: String = stream.user_name
// A loading icon displayed while retrieving the game's name
private val gameNameSpinner = new ProgressIndicator() {
prefWidth <== content.width / 10d
prefHeight <== prefWidth
}
// Open the popout chat in a browser
private val chatBtn = new Button {
text = "Chat"
prefWidth <== content.width
onAction = _ => {
val url = f"https://www.twitch.tv/popout/${stream.user_login.get}/chat"
Browser.openURL(url)
}
}
// Set the content of the menu
override val childrenList = List(
new Label(stream.user_name){prefWidth <== content.width},
gameNameSpinner,
channelBtn,
chatBtn,
followBtn
)
// When displayed, start the async task retrieving the game name and
// add content
override def display(): Unit = {
new Thread() {
override def run(): Unit = {
val msg = try {
stream.getGameName
} catch {
case _: Throwable =>
"Failed to retrieve \n the current game"
}
Platform.runLater({
content.children.remove(gameNameSpinner)
Platform.runLater(content.children.add(1, new Label(msg)))
})
}
}.start()
}
}
package gui.widgets.popover
import api.types.{Stream, User}
import scalafx.scene.control.Label
import scalafx.scene.image.Image
class UserPopover (user: User, followed: Boolean, image: Image)
extends ChannelPopover(followed, image) {
val userLogin: String = user.login
val userID: String = user.id
val userName: String = user.display_name
override val childrenList = List(
new Label(user.display_name){prefWidth <== content.width},
channelBtn,
followBtn
)
override def display(): Unit = {}
}
......@@ -8,28 +8,15 @@ import gui.MainWindow
import gui.panels.{MainPanel, SideMenu}
import pureconfig.generic.auto._
import scalafx.application.Platform
import util.config.APIConfig
object LoginManager {
case class APIConfig (
clientID: String,
oauthScopes: List[String],
oauthBaseURL: String,
oauthRedirectURL: String,
oauthResponseType: String,
)
case class Config (api: APIConfig)
private val conf: Config = pureconfig.loadConfig[Config] match {
case Right(c) => c
case Left(e) => throw new APIConfigError(e.toString)
}
private val oauthURL = conf.api.oauthBaseURL
private val oauthResponseType = conf.api.oauthResponseType
private val oauthRedirectURL = conf.api.oauthRedirectURL
private val oauthScopes = conf.api.oauthScopes
private val clientID = conf.api.clientID
private val oauthURL = APIConfig.getOauthURL
private val oauthResponseType = APIConfig.getOauthResponseType
private val oauthRedirectURL = APIConfig.getOauthRedirectURL
private val oauthScopes = APIConfig.getOauthScopes
private val clientID = APIConfig.getClientID
private var callback: () => Unit = () => {}
......
package util.config
import pureconfig.generic.auto._
import errors.APIConfigError
object APIConfig {
private case class APIParams (
clientID: String,
oauthScopes: List[String],
oauthBaseURL: String,
oauthRedirectURL: String,
oauthResponseType: String,
oauthValidateURL: String,
oauthRevokeURL: String,
streamsEndpoint: String,
topGamesEndpoint: String,
gamesEndpoint: String,
userSubscriptionsEndpoint: String,
userDetailsEndpoint: String,
addFollowEndpoint: String,
defaultItemCount: Int,
maxIDCount: Int
)
private case class Config(api: APIParams)
private val conf: Config = pureconfig.loadConfig[Config] match {
case Right(c) => c
case Left(e) => throw new APIConfigError(e.toString)
}
def getOauthValidateURL: String = conf.api.oauthValidateURL
def getOauthRevokeURL: String = conf.api.oauthRevokeURL
def getOauthURL: String = conf.api.oauthBaseURL
def getOauthResponseType: String = conf.api.oauthResponseType
def getOauthRedirectURL: String = conf.api.oauthRedirectURL
def getOauthScopes: List[String] = conf.api.oauthScopes
def getClientID: String = conf.api.clientID
def getStreamsEndPoint: String = conf.api.streamsEndpoint
def getTopGamesEnPoint: String = conf.api.topGamesEndpoint
def getGamesEndPoint: String = conf.api.gamesEndpoint
def getUserDetailsEndpoint: String = conf.api.userDetailsEndpoint
def getUserSubscriptionsEndpoint: String = conf.api.userSubscriptionsEndpoint
def getAddFollowEndpoint: String = conf.api.addFollowEndpoint
def getDefaultMaxItemCount: Int = conf.api.defaultItemCount
def getmaxIDCount: Int = conf.api.maxIDCount
}
......@@ -9,7 +9,7 @@ import pureconfig.generic.auto._
object UserConfig {
case class Config (
private case class Config (
token: Option[String] = None,
player: Option[String] = None,
streamlink: Option[String] = None,
......