diff --git a/build/tasks/webpack/configurators/tests.js b/build/tasks/webpack/configurators/tests.js index 44fa6a6d35..8ef44806a2 100644 --- a/build/tasks/webpack/configurators/tests.js +++ b/build/tasks/webpack/configurators/tests.js @@ -41,6 +41,17 @@ module.exports = { }); }, + common( config ) { + config.module.rules.push({ + test: /\.ya?ml$/, + include: pTestFixtures, + type: "json", + use: [ + "yaml-loader" + ] + }); + }, + test( config ) { this._tests( config ); this._aliases( config ); diff --git a/src/app/data/models/auth/model.js b/src/app/data/models/auth/model.js index 05e9a2aa7b..e873aebfdf 100644 --- a/src/app/data/models/auth/model.js +++ b/src/app/data/models/auth/model.js @@ -1,26 +1,28 @@ -import { get, computed } from "@ember/object"; +import { computed } from "@ember/object"; import attr from "ember-data/attr"; import Model from "ember-data/model"; +// noinspection JSValidateTypes export default Model.extend( /** @class Auth */ { + /** @type {string} */ access_token: attr( "string" ), - scope : attr( "string" ), - date : attr( "date" ), + /** @type {string} */ + scope: attr( "string" ), + /** @type {Date} */ + date: attr( "date" ), - // volatile property + // state properties user_id: null, user_name: null, - // status properties - isPending : false, + isPending: false, isLoggedIn: computed( "access_token", "user_id", "isPending", function() { - let token = get( this, "access_token" ); - let id = get( this, "user_id" ); - let pending = get( this, "isPending" ); + /** @this {Auth} */ + const { access_token, user_id, isPending } = this; - return token && id && !pending; + return access_token && user_id && !isPending; }) }).reopenClass({ diff --git a/src/app/data/models/search/model.js b/src/app/data/models/search/model.js index cf49bd7b02..c50d0f1ad7 100644 --- a/src/app/data/models/search/model.js +++ b/src/app/data/models/search/model.js @@ -1,42 +1,22 @@ -import { get, computed } from "@ember/object"; import attr from "ember-data/attr"; import Model from "ember-data/model"; -const { hasOwnProperty } = {}; - - -export default Model.extend({ - query : attr( "string" ), +// noinspection JSValidateTypes +export default Model.extend( /** @class Search */ { + /** @type {string} */ + query: attr( "string" ), + /** @type {string} */ filter: attr( "string" ), - date : attr( "date" ), - - label: computed( "filter", function() { - const filter = get( this, "filter" ); - return this.constructor.getLabel( filter ); - }) + /** @type {Date} */ + date: attr( "date" ) }).reopenClass({ toString() { return "Search"; }, filters: [ - { label: "All", id: "all" }, - { label: "Game", id: "games" }, - { label: "Channel", id: "channels" }, - { label: "Stream", id: "streams" } - ], - - filtersmap: computed(function() { - return this.filters.reduce( ( map, filter ) => { - map[ filter.id ] = filter; - return map; - }, {} ); - }), - - getLabel( filter ) { - const map = get( this, "filtersmap" ); - return hasOwnProperty.call( map, filter ) - ? map[ filter ].label - : "All"; - } + { id: "all" }, + { id: "games" }, + { id: "channels" } + ] }); diff --git a/src/app/data/models/settings/gui/fragment.js b/src/app/data/models/settings/gui/fragment.js index ccbab0cf9a..464a3084e7 100644 --- a/src/app/data/models/settings/gui/fragment.js +++ b/src/app/data/models/settings/gui/fragment.js @@ -21,7 +21,7 @@ export default Fragment.extend({ externalcommands: attr( "boolean", { defaultValue: false } ), focusrefresh: attr( "number", { defaultValue: ATTR_GUI_FOCUSREFRESH_NONE } ), hidebuttons: attr( "boolean", { defaultValue: false } ), - homepage: attr( "string", { defaultValue: "/featured" } ), + homepage: attr( "string", { defaultValue: "/streams" } ), integration: attr( "number", { defaultValue: ATTR_GUI_INTEGRATION_BOTH } ), language: attr( "string", { defaultValue: "auto" } ), minimize: attr( "number", { defaultValue: ATTR_GUI_MINIMIZE_NOOP } ), diff --git a/src/app/data/models/stream/model.js b/src/app/data/models/stream/model.js index dd772815ff..8a17c7591c 100644 --- a/src/app/data/models/stream/model.js +++ b/src/app/data/models/stream/model.js @@ -64,14 +64,18 @@ export { }; -/** - * @class Stream - */ -export default Model.extend({ - /** @property {TwitchStream} stream */ - stream: belongsTo( "twitchStream", { async: false } ), - /** @property {TwitchChannel} channel */ - channel: belongsTo( "twitchChannel", { async: false } ), +export default Model.extend( /** @class Stream */ { + /** @type {AuthService} */ + auth: service(), + /** @type {SettingsService} */ + settings: service(), + /** @type {StreamingService} */ + streaming: service(), + + /** @type {TwitchStream} */ + stream: belongsTo( "twitch-stream", { async: false } ), + /** @type {TwitchUser} */ + user: belongsTo( "twitch-user", { async: false } ), quality: attr( "string" ), low_latency: attr( "boolean" ), disable_ads: attr( "boolean" ), @@ -96,12 +100,6 @@ export default Model.extend({ log: null, showLog: false, - - auth: service(), - settings: service(), - streaming: service(), - - session: alias( "auth.session" ), @@ -166,14 +164,14 @@ export default Model.extend({ cpQualityFromPresetOrCustomValue( "quality" ) ), - streamUrl: computed( "channel.name", function() { - const channel = get( this, "channel.name" ); - - return twitchStreamUrl.replace( "{channel}", channel ); - }) + streamUrl: computed( + "stream.user_login", + /** @this {Stream} */ + function() { + return twitchStreamUrl.replace( "{channel}", this.stream.user_login ); + } + ) }).reopenClass({ - toString() { return "Stream"; } - }); diff --git a/src/app/data/models/twitch/adapter.js b/src/app/data/models/twitch/adapter.js index b5cd5ae4f6..f9c6ea455f 100644 --- a/src/app/data/models/twitch/adapter.js +++ b/src/app/data/models/twitch/adapter.js @@ -1,5 +1,4 @@ import { get, observer } from "@ember/object"; -import { inject as service } from "@ember/service"; import RESTAdapter from "ember-data/adapters/rest"; import { twitch } from "config"; import AdapterMixin from "data/models/-mixins/adapter"; @@ -9,42 +8,18 @@ const { oauth: { "client-id": clientId } } = twitch; export default RESTAdapter.extend( AdapterMixin, { - auth: service(), - host: "https://api.twitch.tv", namespace: "", headers: { - "Accept": "application/vnd.twitchtv.v5+json", "Client-ID": clientId }, defaultSerializer: "twitch", - - urlFragments: { - user_id() { - let user_id = get( this, "auth.session.user_id" ); - if ( !user_id ) { - throw new Error( "Unknown user_id" ); - } - - return user_id; - }, - user_name() { - let user_name = get( this, "auth.session.user_name" ); - if ( !user_name ) { - throw new Error( "Unknown user_name" ); - } - - return user_name; - } - }, - - coalesceFindRequests: false, - - findManyIdString: null, - findManyIdSeparator: ",", + findIdParam: null, + findIdSeparator: null, + findIdMax: 100, access_token: null, @@ -53,7 +28,7 @@ export default RESTAdapter.extend( AdapterMixin, { if ( token === null ) { delete this.headers[ "Authorization" ]; } else { - this.headers[ "Authorization" ] = `OAuth ${token}`; + this.headers[ "Authorization" ] = `Bearer ${token}`; } }), @@ -69,61 +44,100 @@ export default RESTAdapter.extend( AdapterMixin, { return {}; }, - findMany( store, type, ids, snapshots ) { - const url = this.buildURL( type, null, snapshots, "findMany" ); + /** + * @param {DS.Store} store + * @param {DS.Model} type + * @param {string} id + * @param {DS.Snapshot} snapshot + * @return {Promise} + */ + findRecord( store, type, id, snapshot ) { + const url = this.buildURL( type, null, snapshot, "findRecord" ); + const paramName = this.findIdParam || store.serializerFor( type.modelName ).primaryKey; const data = { - [ this.findManyIdString ]: ids.join( this.findManyIdSeparator ) + [ paramName ]: id }; return this.ajax( url, "GET", { data } ); }, + /** + * @param {DS.Store} store + * @param {DS.Model} type + * @param {string[]} ids + * @param {DS.Snapshot[]} snapshots + * @return {Promise} + */ + findMany( store, type, ids, snapshots ) { + const url = this.buildURL( type, null, snapshots, "findMany" ); + const paramName = this.findIdParam || store.serializerFor( type.modelName ).primaryKey; + const data = this.findIdSeparator + ? { [ paramName ]: ids.join( this.findIdSeparator ) } + : ids.map( id => ({ name: paramName, value: id }) ); + + return this.ajax( url, "GET", { data } ); + }, + + /** + * @param {DS.Store} store + * @param {DS.Snapshot[]} snapshots + * @return {Array>} + */ groupRecordsForFindMany( store, snapshots ) { const snapshotsByType = new Map(); // group snapshots by type - snapshots.forEach( snapshot => { - const type = snapshot.type; - const typeArray = snapshotsByType.get( type ) || []; - typeArray.push( snapshot ); - snapshotsByType.set( type, typeArray ); - }); + for ( const snapshot of snapshots ) { + const { type } = snapshot; + let snapshotGroup = snapshotsByType.get( type ); + if ( !snapshotGroup ) { + snapshotGroup = []; + snapshotsByType.set( type, snapshotGroup ); + } + snapshotGroup.push( snapshot ); + } // build request groups const groups = []; - snapshotsByType.forEach( ( snapshotGroup, type ) => { + for ( const [ type, snapshotGroup ] of snapshotsByType ) { const adapter = store.adapterFor( type.modelName ); - - const baseLength = adapter._buildURL( type ).length - // "?[findManyIdString]=" - + 2 + adapter.findManyIdString.length; - const findManyIdSeparatorLength = adapter.findManyIdSeparator.length; - const maxLength = adapter.maxURLLength; + const { + maxURLLength, + findIdParam, + findIdMax, + findIdSeparator + } = adapter; + + const baseLength = adapter._buildURL( type ).length; + const paramName = findIdParam || store.serializerFor( type.modelName ).primaryKey; + const paramNameLength = paramName.length; + const findIdSeparatorLength = findIdSeparator?.length || 0; let group = []; let length = baseLength; - snapshotGroup.forEach( snapshot => { - const id = get( snapshot, "record.id" ); - const idLength = String( id ).length; - const separatorLength = group.length === 0 ? 0 : findManyIdSeparatorLength; - const newLength = length + separatorLength + idLength; + for ( const snapshot of snapshotGroup ) { + length += group.length === 0 || findIdSeparatorLength === 0 + // ${url}?${paramName}=${id1} ... &${paramName}=${id2} + ? 2 + paramNameLength + // ${url}?${paramName}=${id1} ... ${findIdSeparator}${id2} + : findIdSeparatorLength; + length += String( snapshot.record.id ).length; - if ( newLength <= maxLength ) { + if ( length <= maxURLLength && group.length < findIdMax ) { group.push( snapshot ); - length = newLength; } else { groups.push( group ); group = [ snapshot ]; length = baseLength; } - }); + } if ( group.length ) { groups.push( group ); } - }); + } return groups; } diff --git a/src/app/data/models/twitch/channel-followed/model.js b/src/app/data/models/twitch/channel-followed/model.js deleted file mode 100644 index 3c30a85123..0000000000 --- a/src/app/data/models/twitch/channel-followed/model.js +++ /dev/null @@ -1,13 +0,0 @@ -import attr from "ember-data/attr"; -import Model from "ember-data/model"; -import { belongsTo } from "ember-data/relationships"; - - -export default Model.extend({ - channel: belongsTo( "twitchChannel", { async: false } ), - created_at: attr( "date" ), - notifications: attr( "boolean" ) - -}).reopenClass({ - toString() { return "kraken/users/:user_id/follows/channels"; } -}); diff --git a/src/app/data/models/twitch/channel-followed/serializer.js b/src/app/data/models/twitch/channel-followed/serializer.js deleted file mode 100644 index a5cbc965f9..0000000000 --- a/src/app/data/models/twitch/channel-followed/serializer.js +++ /dev/null @@ -1,39 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchChannelFollowed"; - }, - - attrs: { - channel: { deserialize: "records" } - }, - - normalizeArrayResponse( store, primaryModelClass, payload, id, requestType ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // fix payload format - const follows = ( payload.follows /* istanbul ignore next */ || [] ); - delete payload.follows; - payload[ this.modelNameFromPayloadKey() ] = follows.map( data => { - data[ this.primaryKey ] = data.channel[ foreignKey ]; - return data; - }); - - return this._super( store, primaryModelClass, payload, id, requestType ); - }, - - normalizeSingleResponse( store, primaryModelClass, payload, id, requestType ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // fix payload format - payload[ this.primaryKey ] = payload.channel[ foreignKey ]; - delete payload.channel; - payload = { - [ this.modelNameFromPayloadKey() ]: payload - }; - - return this._super( store, primaryModelClass, payload, id, requestType ); - } -}); diff --git a/src/app/data/models/twitch/channel/adapter.js b/src/app/data/models/twitch/channel/adapter.js new file mode 100644 index 0000000000..5d5092bf2b --- /dev/null +++ b/src/app/data/models/twitch/channel/adapter.js @@ -0,0 +1,6 @@ +import TwitchAdapter from "data/models/twitch/adapter"; + + +export default TwitchAdapter.extend({ + coalesceFindRequests: true +}); diff --git a/src/app/data/models/twitch/channel/model.js b/src/app/data/models/twitch/channel/model.js index 6c7fb0b4ad..0388cda756 100644 --- a/src/app/data/models/twitch/channel/model.js +++ b/src/app/data/models/twitch/channel/model.js @@ -1,123 +1,14 @@ -import { get, computed } from "@ember/object"; -import { inject as service } from "@ember/service"; import attr from "ember-data/attr"; import Model from "ember-data/model"; -import { - ATTR_STREAMS_NAME_CUSTOM, - ATTR_STREAMS_NAME_ORIGINAL, - ATTR_STREAMS_NAME_BOTH -} from "data/models/settings/streams/fragment"; - - -const reLang = /^([a-z]{2})(:?-([a-z]{2}))?$/; - - -export default Model.extend({ - /** @type {IntlService} */ - intl: service(), - settings: service(), +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchChannel */ { + /** @type {string} */ broadcaster_language: attr( "string" ), - created_at: attr( "date" ), - display_name: attr( "string" ), - followers: attr( "number" ), - game: attr( "string" ), - language: attr( "string" ), - logo: attr( "string" ), - mature: attr( "boolean" ), - name: attr( "string" ), - partner: attr( "boolean" ), - profile_banner: attr( "string" ), - profile_banner_background_color: attr( "string" ), - status: attr( "string" ), - updated_at: attr( "date" ), - url: attr( "string" ), - video_banner: attr( "string" ), - views: attr( "number" ), - - - hasCustomDisplayName: computed( "name", "display_name", function() { - return get( this, "name" ).toLowerCase() !== get( this, "display_name" ).toLowerCase(); - }), - - detailedName: computed( - "name", - "display_name", - "hasCustomDisplayName", - "settings.streams.name", - function() { - switch ( get( this, "settings.streams.name" ) ) { - case ATTR_STREAMS_NAME_CUSTOM: - return get( this, "display_name" ); - case ATTR_STREAMS_NAME_ORIGINAL: - return get( this, "name" ); - case ATTR_STREAMS_NAME_BOTH: - return get( this, "hasCustomDisplayName" ) - ? `${get( this, "display_name" )} (${get( this, "name" )})` - : get( this, "display_name" ); - } - } - ), - - - titleFollowers: computed( "intl.locale", "followers", function() { - return this.intl.t( "models.twitch.channel.followers", { count: this.followers } ); - }), - - titleViews: computed( "intl.locale", "views", function() { - return this.intl.t( "models.twitch.channel.views", { count: this.views } ); - }), - - - hasLanguage: computed( "language", function() { - const lang = get( this, "language" ); - - return !!lang && lang !== "other"; - }), - - hasBroadcasterLanguage: computed( "broadcaster_language", "language", function() { - const broadcaster = get( this, "broadcaster_language" ); - const language = get( this, "language" ); - const mBroadcaster = reLang.exec( broadcaster ); - const mLanguage = reLang.exec( language ); - - // show the broadcaster_language only if it is set and - // 1. the language is not set or - // 2. the language is different from the broadcaster_language - // WITHOUT comparing both lang variants - return !!mBroadcaster && ( !mLanguage || mLanguage[1] !== mBroadcaster[1] ); - }), - - - /** @type {(TwitchSubscription|boolean)} subscribed */ - subscribed: null, - - /** @type {(TwitchChannelFollowed|boolean)} following */ - followed: null, - - /** - * Load channel specific settings - * @returns {Promise} - */ - getChannelSettings() { - const store = get( this, "store" ); - const id = get( this, "name" ); - - // For a very weird reason, exceptions thrown by store.findRecord() are not being caught - // when using an async function and a try/catch block with the await keyword. - // So use a regular promise chain here instead. - return store.findRecord( "channelSettings", id ) - .catch( () => store.recordForId( "channelSettings", id ) ) - .then( record => { - // get the record's data and then unload it - const data = record.toJSON(); - store.unloadRecord( record ); - - return data; - }); - } + /** @type {number} */ + delay: attr( "number" ) }).reopenClass({ - toString() { return "kraken/channels"; } + toString() { return "helix/channels"; } }); diff --git a/src/app/data/models/twitch/channel/serializer.js b/src/app/data/models/twitch/channel/serializer.js index 1d5f9c4800..4921ca0d04 100644 --- a/src/app/data/models/twitch/channel/serializer.js +++ b/src/app/data/models/twitch/channel/serializer.js @@ -2,15 +2,9 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchChannel"; - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - payload = { - twitchChannel: payload - }; + primaryKey: "broadcaster_id", - return this._super( store, primaryModelClass, payload, id, requestType ); + modelNameFromPayloadKey() { + return "twitch-channel"; } }); diff --git a/src/app/data/models/twitch/game-top/model.js b/src/app/data/models/twitch/game-top/model.js index 139a56b9cf..69f47c4b01 100644 --- a/src/app/data/models/twitch/game-top/model.js +++ b/src/app/data/models/twitch/game-top/model.js @@ -1,13 +1,11 @@ -import attr from "ember-data/attr"; import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; -export default Model.extend({ - channels: attr( "number" ), - game: belongsTo( "twitchGame", { async: false } ), - viewers: attr( "number" ) +export default Model.extend( /** @class TwitchGameTop */ { + /** @type {TwitchGame} */ + game: belongsTo( "twitch-game", { async: false } ) }).reopenClass({ - toString() { return "kraken/games/top"; } + toString() { return "helix/games/top"; } }); diff --git a/src/app/data/models/twitch/game-top/serializer.js b/src/app/data/models/twitch/game-top/serializer.js index f3971767b9..2f416e5b3e 100644 --- a/src/app/data/models/twitch/game-top/serializer.js +++ b/src/app/data/models/twitch/game-top/serializer.js @@ -3,7 +3,7 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ modelNameFromPayloadKey() { - return "twitchGameTop"; + return "twitch-game-top"; }, attrs: { @@ -11,10 +11,11 @@ export default TwitchSerializer.extend({ }, normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchGame" ).primaryKey; - - // get the id of the embedded TwitchGame record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.game[ foreignKey ]; + const foreignKey = this.store.serializerFor( "twitch-game" ).primaryKey; + resourceHash = { + [ this.primaryKey ]: resourceHash[ foreignKey ], + game: resourceHash + }; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/data/models/twitch/game/adapter.js b/src/app/data/models/twitch/game/adapter.js new file mode 100644 index 0000000000..a4646ad82b --- /dev/null +++ b/src/app/data/models/twitch/game/adapter.js @@ -0,0 +1,13 @@ +import TwitchAdapter from "data/models/twitch/adapter"; + + +const returnFalse = () => false; + + +export default TwitchAdapter.extend({ + coalesceFindRequests: true, + + // never reload TwitchGame records + shouldReloadRecord: returnFalse, + shouldBackgroundReloadRecord: returnFalse +}); diff --git a/src/app/data/models/twitch/game/model.js b/src/app/data/models/twitch/game/model.js index 38e7f33a2b..e437c919c2 100644 --- a/src/app/data/models/twitch/game/model.js +++ b/src/app/data/models/twitch/game/model.js @@ -1,15 +1,14 @@ import attr from "ember-data/attr"; import Model from "ember-data/model"; -import { belongsTo } from "ember-data/relationships"; -export default Model.extend({ - box: belongsTo( "twitchImage", { async: false } ), - //giantbomb_id: attr( "number" ), - logo: belongsTo( "twitchImage", { async: false } ), +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchGame */ { + /** @type {TwitchImage} */ + box_art_url: attr( "twitch-image", { width: 285, height: 380, expiration: 0 } ), + /** @type {string} */ name: attr( "string" ) - //popularity: attr( "number" ) }).reopenClass({ - toString() { return "kraken/games"; } + toString() { return "helix/games"; } }); diff --git a/src/app/data/models/twitch/game/serializer.js b/src/app/data/models/twitch/game/serializer.js index 0b3bde3e0a..39449d886f 100644 --- a/src/app/data/models/twitch/game/serializer.js +++ b/src/app/data/models/twitch/game/serializer.js @@ -2,29 +2,7 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ - primaryKey: "name", - modelNameFromPayloadKey() { - return "twitchGame"; - }, - - attrs: { - box: { deserialize: "records" }, - logo: { deserialize: "records" } - }, - - normalize( modelClass, resourceHash, prop ) { - const id = resourceHash[ this.primaryKey ]; - const foreignKey = this.store.serializerFor( "twitchImage" ).primaryKey; - - // apply the id of this record to the embedded TwitchImage records (box and logo) - if ( resourceHash.box ) { - resourceHash.box[ foreignKey ] = `game/box/${id}`; - } - if ( resourceHash.logo ) { - resourceHash.logo[ foreignKey ] = `game/logo/${id}`; - } - - return this._super( modelClass, resourceHash, prop ); + return "twitch-game"; } }); diff --git a/src/app/data/models/twitch/image/model.js b/src/app/data/models/twitch/image/model.js deleted file mode 100644 index 5f07bce9a0..0000000000 --- a/src/app/data/models/twitch/image/model.js +++ /dev/null @@ -1,78 +0,0 @@ -import { get, computed } from "@ember/object"; -import attr from "ember-data/attr"; -import Model from "ember-data/model"; -import { vars } from "config"; - - -const { "image-expiration-time": time } = vars; - - -function getURL( url, time ) { - return `${url}?_=${time}`; -} - -/** - * Return the image URL with an already set expiration time. - * Or create a new expiration time if it hasn't been set yet. - * @param {String} attr - * @returns {Ember.ComputedProperty} Volatile computed property - */ -function buffered( attr ) { - return computed(function() { - let exp = this[ `expiration_${attr}` ]; - - return exp - ? getURL( get( this, `image_${attr}` ), exp ) - : get( this, `${attr}Latest` ); - }).volatile(); -} - -/** - * Return the image URL with an expiration parameter, so the latest version will be requested. - * Update the expiration timer only once every X seconds. - * @param {String} attr - * @returns {Ember.ComputedProperty} Volatile computed property - */ -function latest( attr ) { - // use a volatile property - return computed(function() { - const url = get( this, `image_${attr}` ); - - // use the same timestamp for `time` seconds - const key = `expiration_${attr}`; - const now = Date.now(); - let exp = this[ key ]; - - if ( !exp || exp <= now ) { - exp = now + time; - this[ key ] = exp; - } - - return getURL( url, exp ); - }).volatile(); -} - - -export default Model.extend({ - // original attributes (renamed) - image_large: attr( "string" ), - image_medium: attr( "string" ), - image_small: attr( "string" ), - - // expiration times - expiration_large: null, - expiration_medium: null, - expiration_small: null, - - // "request latest image version, but only every X seconds" - // should be used by a route's model hook - largeLatest: latest( "large" ), - mediumLatest: latest( "medium" ), - smallLatest: latest( "small" ), - - // "use the previous expiration parameter" - // should be used by all image src attributes in the DOM - large: buffered( "large" ), - medium: buffered( "medium" ), - small: buffered( "small" ) -}); diff --git a/src/app/data/models/twitch/image/serializer.js b/src/app/data/models/twitch/image/serializer.js deleted file mode 100644 index fe313266d2..0000000000 --- a/src/app/data/models/twitch/image/serializer.js +++ /dev/null @@ -1,12 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - normalize( modelClass, resourceHash, prop ) { - resourceHash.image_small = resourceHash.small; - resourceHash.image_medium = resourceHash.medium; - resourceHash.image_large = resourceHash.large; - - return this._super( modelClass, resourceHash, prop ); - } -}); diff --git a/src/app/data/models/twitch/root/model.js b/src/app/data/models/twitch/root/model.js deleted file mode 100644 index 7a9021da7f..0000000000 --- a/src/app/data/models/twitch/root/model.js +++ /dev/null @@ -1,20 +0,0 @@ -import attr from "ember-data/attr"; -import Model from "ember-data/model"; -import { array } from "ember-data-model-fragments/attributes"; - - -/** - * @class TwitchRoot - * @extends Model - */ -export default Model.extend({ - created_at: attr( "date" ), - scopes: array( "string" ), - updated_at: attr( "date" ), - user_id: attr( "number" ), - user_name: attr( "string" ), - valid: attr( "boolean" ) - -}).reopenClass({ - toString() { return "kraken/"; } -}); diff --git a/src/app/data/models/twitch/root/serializer.js b/src/app/data/models/twitch/root/serializer.js deleted file mode 100644 index d27542972d..0000000000 --- a/src/app/data/models/twitch/root/serializer.js +++ /dev/null @@ -1,21 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchRoot"; - }, - - normalize( modelClass, resourceHash, prop ) { - // add an ID to the record - resourceHash[ this.primaryKey ] = 1; - - const authorization = resourceHash.authorization /* istanbul ignore next */ || {}; - const { scopes, created_at, updated_at } = authorization; - resourceHash.scopes = scopes; - resourceHash.created_at = created_at; - resourceHash.updated_at = updated_at; - - return this._super( modelClass, resourceHash, prop ); - } -}); diff --git a/src/app/data/models/twitch/search-channel/model.js b/src/app/data/models/twitch/search-channel/model.js index 91ab6ca044..53ffa7686e 100644 --- a/src/app/data/models/twitch/search-channel/model.js +++ b/src/app/data/models/twitch/search-channel/model.js @@ -1,10 +1,94 @@ +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import attr from "ember-data/attr"; import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; +import { DEFAULT_VODCAST_REGEXP } from "data/models/settings/streams/fragment"; -export default Model.extend({ - channel: belongsTo( "twitchChannel", { async: false } ) +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchSearchChannel */ { + /** @type {IntlService} */ + intl: service(), + /** @type {SettingsService} */ + settings: service(), + + /** @type {TwitchUser} */ + user: belongsTo( "twitch-user", { async: true } ), + /** @type {string} */ + broadcaster_language: attr( "string" ), + /** @type {string} */ + broadcaster_login: attr( "string" ), + /** @type {string} */ + display_name: attr( "string" ), + /** @type {string} */ + game: belongsTo( "twitch-game", { async: true } ), + /** @type {string} */ + game_name: attr( "string" ), + /** @type {boolean} */ + is_live: attr( "boolean" ), + /** @type {TwitchImage} */ + thumbnail_url: attr( "twitch-image", { width: 640, height: 360 } ), + /** @type {string} */ + title: attr( "string" ), + /** @type {Date} */ + started_at: attr( "date" ), + + + /** @type {(null|RegExp)} */ + reVodcast: computed( + "settings.content.streams.vodcast_regexp", + /** @this {TwitchStream} */ + function() { + const vodcast_regexp = this.settings.content.streams.vodcast_regexp; + + if ( vodcast_regexp.length && !vodcast_regexp.trim().length ) { + return null; + } + try { + return new RegExp( vodcast_regexp || DEFAULT_VODCAST_REGEXP, "i" ); + } catch ( e ) { + return null; + } + } + ), + + /** @type {boolean} */ + isVodcast: computed( + "reVodcast", + "title", + /** @this {TwitchStream} */ + function() { + const { reVodcast, title } = this; + + return reVodcast && title && reVodcast.test( title ); + } + ), + + /** @type {string} */ + titleStartedAt: computed( + "intl.locale", + "started_at", + /** @this {TwitchStream} */ + function() { + const { started_at } = this; + + return !started_at + ? this.intl.t( + "models.twitch.search-channel.started-at.offline" + ) + : new Date() - started_at < 24 * 3600 * 1000 + ? this.intl.t( + "models.twitch.search-channel.started-at.less-than-24h", + { started_at } + ) + : this.intl.t( + "models.twitch.search-channel.started-at.more-than-24h", + { started_at } + ); + } + ) }).reopenClass({ - toString() { return "kraken/search/channels"; } + toString() { return "helix/search/channels"; } }); diff --git a/src/app/data/models/twitch/search-channel/serializer.js b/src/app/data/models/twitch/search-channel/serializer.js index 48bc5b0e12..00d3ee435d 100644 --- a/src/app/data/models/twitch/search-channel/serializer.js +++ b/src/app/data/models/twitch/search-channel/serializer.js @@ -3,25 +3,13 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ modelNameFromPayloadKey() { - return "twitchSearchChannel"; - }, - - attrs: { - channel: { deserialize: "records" } - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload.channels = ( payload.channels || [] ).map( channel => ({ channel }) ); - - return this._super( store, primaryModelClass, payload, id, requestType ); + return "twitch-search-channel"; }, normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.channel[ foreignKey ]; + resourceHash.user = resourceHash[ this.primaryKey ]; + resourceHash.game = resourceHash.game_id; + delete resourceHash[ "game_id" ]; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/data/models/twitch/search-game/model.js b/src/app/data/models/twitch/search-game/model.js index 46bdd512ea..184b11e23a 100644 --- a/src/app/data/models/twitch/search-game/model.js +++ b/src/app/data/models/twitch/search-game/model.js @@ -2,9 +2,10 @@ import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; -export default Model.extend({ - game: belongsTo( "twitchGame", { async: false } ) +export default Model.extend( /** @class TwitchSearchGame */ { + /** @type {TwitchGame} */ + game: belongsTo( "twitch-game", { async: false } ) }).reopenClass({ - toString() { return "kraken/search/games"; } + toString() { return "helix/search/categories"; } }); diff --git a/src/app/data/models/twitch/search-game/serializer.js b/src/app/data/models/twitch/search-game/serializer.js index ffc84432ba..392cc45830 100644 --- a/src/app/data/models/twitch/search-game/serializer.js +++ b/src/app/data/models/twitch/search-game/serializer.js @@ -1,27 +1,32 @@ import TwitchSerializer from "data/models/twitch/serializer"; +const reStaticBoxArtRes = /\d+x\d+\.(\w+)$/; + + export default TwitchSerializer.extend({ modelNameFromPayloadKey() { - return "twitchSearchGame"; + return "twitch-search-game"; }, attrs: { game: { deserialize: "records" } }, - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload.games = ( payload.games || [] ).map( game => ({ game }) ); - - return this._super( store, primaryModelClass, payload, id, requestType ); - }, - normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchGame" ).primaryKey; - - // get the id of the embedded TwitchGame record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.game[ foreignKey ]; + const { primaryKey } = this; + + // workaround for: https://github.com/twitchdev/issues/issues/329 + /* istanbul ignore next */ + if ( resourceHash[ "box_art_url" ] ) { + resourceHash[ "box_art_url" ] = `${resourceHash[ "box_art_url" ]}` + .replace( reStaticBoxArtRes, "{width}x{height}.$1" ); + } + + resourceHash = { + [ primaryKey ]: resourceHash[ primaryKey ], + game: resourceHash + }; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/data/models/twitch/search-stream/model.js b/src/app/data/models/twitch/search-stream/model.js deleted file mode 100644 index 72ee9a1471..0000000000 --- a/src/app/data/models/twitch/search-stream/model.js +++ /dev/null @@ -1,10 +0,0 @@ -import Model from "ember-data/model"; -import { belongsTo } from "ember-data/relationships"; - - -export default Model.extend({ - stream: belongsTo( "twitchStream", { async: false } ) - -}).reopenClass({ - toString() { return "kraken/search/streams"; } -}); diff --git a/src/app/data/models/twitch/search-stream/serializer.js b/src/app/data/models/twitch/search-stream/serializer.js deleted file mode 100644 index 810ce61b54..0000000000 --- a/src/app/data/models/twitch/search-stream/serializer.js +++ /dev/null @@ -1,28 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchSearchStream"; - }, - - attrs: { - stream: { deserialize: "records" } - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload.streams = ( payload.streams || [] ).map( stream => ({ stream }) ); - - return this._super( store, primaryModelClass, payload, id, requestType ); - }, - - normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.stream.channel[ foreignKey ]; - - return this._super( modelClass, resourceHash, prop ); - } -}); diff --git a/src/app/data/models/twitch/serializer.js b/src/app/data/models/twitch/serializer.js index 7fe94c8303..75f327f98a 100644 --- a/src/app/data/models/twitch/serializer.js +++ b/src/app/data/models/twitch/serializer.js @@ -2,29 +2,83 @@ import EmbeddedRecordsMixin from "ember-data/serializers/embedded-records-mixin" import RESTSerializer from "ember-data/serializers/rest"; +const { hasOwnProperty } = {}; + + export default RESTSerializer.extend( EmbeddedRecordsMixin, { isNewSerializerAPI: true, - primaryKey: "_id", + primaryKey: "id", + + /** + * Override "data" payload key with model name recognized by EmberData + * @param {DS.Store} store + * @param {DS.Model} primaryModelClass + * @param {Object} payload + * @param {string|null} id + * @param {string} requestType + * @return {Object} + */ + normalizeResponse( store, primaryModelClass, payload, id, requestType ) { + if ( !payload || !hasOwnProperty.call( payload, "data" ) ) { + throw new Error( "Unknown payload format of the API response" ); + } + + const key = this.modelNameFromPayloadKey(); + payload[ key ] = payload[ "data" ] /* istanbul ignore next */ || []; + delete payload[ "data" ]; + + return this._super( store, primaryModelClass, payload, id, requestType ); + }, + + /** + * Turn Twitch's array response into a single response + * @param {DS.Store} store + * @param {DS.Model} primaryModelClass + * @param {Object} payload + * @param {string|null} id + * @param {string} requestType + * @return {Object} + */ + normalizeSingleResponse( store, primaryModelClass, payload, id, requestType ) { + const key = this.modelNameFromPayloadKey(); + payload[ key ] = payload[ key ][0] /* istanbul ignore next */ || null; + + return this._super( store, primaryModelClass, payload, id, requestType ); + }, /** - * All underscored properties contain metadata (except the primaryKey) + * Extract metadata and remove metadata properties from payload + * Everything except the model-name (overridden by normalizeResponse) is considered metadata * @param {DS.Store} store * @param {DS.Model} type * @param {Object} payload */ extractMeta( store, type, payload ) { + /* istanbul ignore next */ if ( !payload ) { return; } - const primaryKey = this.primaryKey; const data = {}; - - Object.keys( payload ).forEach(function( key ) { - if ( key.charAt( 0 ) === "_" && key !== primaryKey ) { - data[ key.substr( 1 ) ] = payload[ key ]; - delete payload[ key ]; + const dataKey = this.modelNameFromPayloadKey(); + for ( const [ key, value ] of Object.entries( payload ) ) { + if ( key === dataKey ) { continue; } + const type = typeof value; + switch ( key ) { + case "pagination": + if ( type === "object" && hasOwnProperty.call( value, "cursor" ) ) { + data[ "pagination" ] = { + cursor: String( value[ "cursor" ] ) + }; + } + break; + default: + // ignore non-primitive values + if ( type === "string" || type === "number" ) { + data[ key ] = value; + } } - }); + delete payload[ key ]; + } return data; } diff --git a/src/app/data/models/twitch/stream-featured/model.js b/src/app/data/models/twitch/stream-featured/model.js deleted file mode 100644 index d450bae1c8..0000000000 --- a/src/app/data/models/twitch/stream-featured/model.js +++ /dev/null @@ -1,17 +0,0 @@ -import attr from "ember-data/attr"; -import Model from "ember-data/model"; -import { belongsTo } from "ember-data/relationships"; - - -export default Model.extend({ - image: attr( "string" ), - priority: attr( "number" ), - scheduled: attr( "boolean" ), - sponsored: attr( "boolean" ), - stream: belongsTo( "twitchStream", { async: false } ), - text: attr( "string" ), - title: attr( "string" ) - -}).reopenClass({ - toString() { return "kraken/streams/featured"; } -}); diff --git a/src/app/data/models/twitch/stream-featured/serializer.js b/src/app/data/models/twitch/stream-featured/serializer.js deleted file mode 100644 index e40ea0bfa9..0000000000 --- a/src/app/data/models/twitch/stream-featured/serializer.js +++ /dev/null @@ -1,21 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchStreamFeatured"; - }, - - attrs: { - stream: { deserialize: "records" } - }, - - normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.stream.channel[ foreignKey ]; - - return this._super( modelClass, resourceHash, prop ); - } -}); diff --git a/src/app/data/models/twitch/stream-followed/model.js b/src/app/data/models/twitch/stream-followed/model.js index 6ee90dbae9..be609bd522 100644 --- a/src/app/data/models/twitch/stream-followed/model.js +++ b/src/app/data/models/twitch/stream-followed/model.js @@ -2,9 +2,10 @@ import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; -export default Model.extend({ - stream: belongsTo( "twitchStream", { async: false } ) +export default Model.extend( /** @class TwitchStreamFollowed */ { + /** @type {TwitchStream} */ + stream: belongsTo( "twitch-stream", { async: false } ) }).reopenClass({ - toString() { return "kraken/streams/followed"; } + toString() { return "helix/streams/followed"; } }); diff --git a/src/app/data/models/twitch/stream-followed/serializer.js b/src/app/data/models/twitch/stream-followed/serializer.js index 7e8d99b2dc..d8bf658f70 100644 --- a/src/app/data/models/twitch/stream-followed/serializer.js +++ b/src/app/data/models/twitch/stream-followed/serializer.js @@ -3,25 +3,19 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ modelNameFromPayloadKey() { - return "twitchStreamFollowed"; + return "twitch-stream-followed"; }, attrs: { stream: { deserialize: "records" } }, - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload.streams = ( payload.streams || [] ).map( stream => ({ stream }) ); - - return this._super( store, primaryModelClass, payload, id, requestType ); - }, - normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.stream.channel[ foreignKey ]; + const foreignKey = this.store.serializerFor( "twitch-stream" ).primaryKey; + resourceHash = { + [ this.primaryKey ]: resourceHash[ foreignKey ], + stream: resourceHash + }; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/data/models/twitch/stream-summary/model.js b/src/app/data/models/twitch/stream-summary/model.js deleted file mode 100644 index e45af39e3f..0000000000 --- a/src/app/data/models/twitch/stream-summary/model.js +++ /dev/null @@ -1,11 +0,0 @@ -import attr from "ember-data/attr"; -import Model from "ember-data/model"; - - -export default Model.extend({ - channels: attr( "number" ), - viewers: attr( "number" ) - -}).reopenClass({ - toString() { return "kraken/streams/summary"; } -}); diff --git a/src/app/data/models/twitch/stream-summary/serializer.js b/src/app/data/models/twitch/stream-summary/serializer.js deleted file mode 100644 index 04ab2c9e1a..0000000000 --- a/src/app/data/models/twitch/stream-summary/serializer.js +++ /dev/null @@ -1,20 +0,0 @@ -import TwitchSerializer from "data/models/twitch/serializer"; - - -export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchStreamSummary"; - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // always use 1 as id - payload[ this.primaryKey ] = 1; - - // fix payload format - payload = { - twitchStreamSummary: payload - }; - - return this._super( store, primaryModelClass, payload, id, requestType ); - } -}); diff --git a/src/app/data/models/twitch/stream/adapter.js b/src/app/data/models/twitch/stream/adapter.js index b08b6ac38a..fe84bc08e1 100644 --- a/src/app/data/models/twitch/stream/adapter.js +++ b/src/app/data/models/twitch/stream/adapter.js @@ -3,5 +3,16 @@ import TwitchAdapter from "data/models/twitch/adapter"; export default TwitchAdapter.extend({ coalesceFindRequests: true, - findManyIdString: "channel" + + /** + * @param {DS.Store} store + * @param {DS.Model} type + * @param {Object} query + * @return {Promise} + */ + queryRecord( store, type, query ) { + const url = this._buildURL( type ); + + return this.ajax( url, "GET", { data: query } ); + } }); diff --git a/src/app/data/models/twitch/stream/model.js b/src/app/data/models/twitch/stream/model.js index df79485b4b..ef11f265d0 100644 --- a/src/app/data/models/twitch/stream/model.js +++ b/src/app/data/models/twitch/stream/model.js @@ -1,136 +1,151 @@ import { get, computed } from "@ember/object"; -import { and } from "@ember/object/computed"; import { inject as service } from "@ember/service"; import attr from "ember-data/attr"; import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; import { DEFAULT_VODCAST_REGEXP } from "data/models/settings/streams/fragment"; +import { getChannelSettings } from "data/models/twitch/user/model"; -/** - * @typedef {Object} FPSRange - * @property {Number} target - * @property {Number} min - * @property {Number} max - */ - -/** - * Try to fix the weird fps numbers received from twitch... - * Define a list of common stream frame rates and give each one a min and max value range. - * @type {FPSRange[]} - */ -const fpsRanges = [ - { target: 24, min: 22, max: 24.5 }, - { target: 25, min: 24.5, max: 27 }, - { target: 30, min: 28, max: 32 }, - { target: 45, min: 43, max: 46.5 }, - { target: 48, min: 46.5, max: 49.5 }, - { target: 50, min: 49.5, max: 52 }, - { target: 60, min: 58, max: 62.5 }, - { target: 72, min: 70, max: 74 }, - { target: 90, min: 88, max: 92 }, - { target: 100, min: 98, max: 102 }, - { target: 120, min: 118, max: 122 }, - { target: 144, min: 142, max: 146 } -]; - -const reRerun = /rerun|watch_party/; - - -export default Model.extend({ +const reLang = /^([a-z]{2})(:?-([a-z]{2}))?$/; + + +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchStream */ { /** @type {IntlService} */ intl: service(), + /** @type {SettingsService} */ settings: service(), + /** @type {TwitchUser} */ + user: belongsTo( "twitch-user", { async: true } ), + /** @type {TwitchChannel} */ + channel: belongsTo( "twitch-channel", { async: true } ), + /** @type {TwitchGame} */ + game: belongsTo( "twitch-game", { async: true } ), + + // "user_id" is already defined as the primaryKey of TwitchStream + // accessing the record's "user_id" can be done via "record.id" + /** @type {string} */ + user_login: attr( "string" ), + /** @type {string} */ + user_name: attr( "string" ), + /** @type {string} */ + game_id: attr( "string" ), + /** @type {string} */ + game_name: attr( "string" ), + /** @type {string} */ + type: attr( "string" ), + /** @type {string} */ + title: attr( "string" ), + /** @type {number} */ + viewer_count: attr( "number" ), + /** @type {Date} */ + started_at: attr( "date" ), + /** @type {string} */ + language: attr( "string" ), + /** @type {TwitchImage} */ + thumbnail_url: attr( "twitch-image", { width: 640, height: 360 } ), + /** @type {boolean} */ + is_mature: attr( "boolean" ), + + + /** @type {(null|RegExp)} */ + reVodcast: computed( + "settings.content.streams.vodcast_regexp", + /** @this {TwitchStream} */ + function() { + const vodcast_regexp = this.settings.content.streams.vodcast_regexp; - average_fps: attr( "number" ), - broadcast_platform: attr( "string" ), - channel: belongsTo( "twitchChannel", { async: false } ), - created_at: attr( "date" ), - delay: attr( "number" ), - game: attr( "string" ), - //is_playlist: attr( "boolean" ), - preview: belongsTo( "twitchImage", { async: false } ), - stream_type: attr( "string" ), - video_height: attr( "number" ), - viewers: attr( "number" ), - - - reVodcast: computed( "settings.content.streams.vodcast_regexp", function() { - const vodcast_regexp = get( this, "settings.content.streams.vodcast_regexp" ); - if ( vodcast_regexp.length && !vodcast_regexp.trim().length ) { - return null; - } - try { - return new RegExp( vodcast_regexp || DEFAULT_VODCAST_REGEXP, "i" ); - } catch ( e ) { - return null; + if ( vodcast_regexp.length && !vodcast_regexp.trim().length ) { + return null; + } + try { + return new RegExp( vodcast_regexp || DEFAULT_VODCAST_REGEXP, "i" ); + } catch ( e ) { + return null; + } } - }), + ), - // both properties are not documented in the v5 API + /** @type {boolean} */ isVodcast: computed( - "broadcast_platform", - "stream_type", "reVodcast", - "channel.status", + "title", + /** @this {TwitchStream} */ function() { - if ( - reRerun.test( get( this, "broadcast_platform" ) ) - || reRerun.test( get( this, "stream_type" ) ) - ) { - return true; - } + const { reVodcast, title } = this; - const reVodcast = get( this, "reVodcast" ); - const status = get( this, "channel.status" ); - - return reVodcast && status - ? reVodcast.test( status ) - : false; + return reVodcast && title && reVodcast.test( title ); } ), + /** @type {string} */ + titleStartedAt: computed( + "intl.locale", + "started_at", + /** @this {TwitchStream} */ + function() { + const { started_at } = this; - hasFormatInfo: and( "video_height", "average_fps" ), - - - titleCreatedAt: computed( "intl.locale", "created_at", function() { - const { created_at } = this; - const last24h = new Date() - created_at < 24 * 3600 * 1000; - return last24h - ? this.intl.t( "models.twitch.stream.created-at.less-than-24h", { created_at } ) - : this.intl.t( "models.twitch.stream.created-at.more-than-24h", { created_at } ); - }), - - titleViewers: computed( "intl.locale", "viewers", function() { - return this.intl.t( "models.twitch.stream.viewers", { count: this.viewers } ); - }), + return new Date() - started_at < 24 * 3600 * 1000 + ? this.intl.t( "models.twitch.stream.started-at.less-than-24h", { started_at } ) + : this.intl.t( "models.twitch.stream.started-at.more-than-24h", { started_at } ); + } + ), - resolution: computed( "video_height", function() { - // assume 16:9 - const video_height = get( this, "video_height" ); - const width = Math.round( ( 16 / 9 ) * video_height ); - const height = Math.round( video_height ); + /** @type {string} */ + titleViewers: computed( + "intl.locale", + "viewer_count", + /** @this {TwitchStream} */ + function() { + return this.intl.t( "models.twitch.stream.viewer_count", { count: this.viewer_count } ); + } + ), - return `${width}x${height}`; - }), + /** @type {boolean} */ + hasLanguage: computed( + "language", + /** @this {TwitchStream} */ + function() { + const { language } = this; - fps: computed( "average_fps", function() { - const average_fps = get( this, "average_fps" ); + return !!language && language !== "other"; + } + ), - if ( !average_fps ) { return null; } + /** @type {boolean} */ + hasBroadcasterLanguage: computed( + "language", + "channel.broadcaster_language", + /** @this {TwitchStream} */ + function() { + const { language } = this; + // Ember.get() required here for ObjectProxy access + const broadcaster_language = get( this, "channel.broadcaster_language" ); + + const mLanguage = reLang.exec( language ); + const mBroadcaster = reLang.exec( broadcaster_language ); + + // show the broadcaster_language only if it is set and + // 1. the language is not set or + // 2. the language differs from the broadcaster_language + // WITHOUT comparing both lang variants + return !!mBroadcaster && ( !mLanguage || mLanguage[ 1 ] !== mBroadcaster[ 1 ] ); + } + ), - const fpsRange = fpsRanges.find( fpsRange => - average_fps > fpsRange.min - && average_fps <= fpsRange.max - ); + /** + * Load channel specific settings (without loading the TwitchUser belongsTo relationship) + * @returns {Promise} + */ + async getChannelSettings() { + const { store, id } = this; - return fpsRange - ? fpsRange.target - : Math.floor( average_fps ); - }) + return await getChannelSettings( store, id ); + } }).reopenClass({ - toString() { return "kraken/streams"; } + toString() { return "helix/streams"; } }); diff --git a/src/app/data/models/twitch/stream/serializer.js b/src/app/data/models/twitch/stream/serializer.js index 776c6ca736..1f74489048 100644 --- a/src/app/data/models/twitch/stream/serializer.js +++ b/src/app/data/models/twitch/stream/serializer.js @@ -2,28 +2,23 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ - modelNameFromPayloadKey() { - return "twitchStream"; - }, + // streams can only be uniquely queried by "user_id" + // simply set the primaryKey to it and ignore the regular stream "id" + // this allows us to reload the TwitchStream model + // but store.findRecord will require the user_id to be set + primaryKey: "user_id", - attrs: { - channel: { deserialize: "records" }, - preview: { deserialize: "records" } + modelNameFromPayloadKey() { + return "twitch-stream"; }, normalize( modelClass, resourceHash, prop ) { - const foreignKeyChannel = this.store.serializerFor( "twitchChannel" ).primaryKey; - const foreignKeyImage = this.store.serializerFor( "twitchImage" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - // this is required for refreshing the record so that the correct id is being used - const id = resourceHash.channel[ foreignKeyChannel ]; - resourceHash[ this.primaryKey ] = id; - - // apply the id of this record on the embedded TwitchImage record (preview) - if ( resourceHash.preview ) { - resourceHash.preview[ foreignKeyImage ] = `stream/preview/${id}`; - } + const { primaryKey } = this; + resourceHash[ "user" ] = resourceHash[ primaryKey ]; + resourceHash[ "channel" ] = resourceHash[ primaryKey ]; + // game IDs can be empty strings + resourceHash[ "game_id" ] = resourceHash[ "game" ] + = resourceHash[ "game_id" ] /* istanbul ignore next */ || null; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/data/models/twitch/team/adapter.js b/src/app/data/models/twitch/team/adapter.js index cf52a49ad9..ce1373bd9e 100644 --- a/src/app/data/models/twitch/team/adapter.js +++ b/src/app/data/models/twitch/team/adapter.js @@ -3,20 +3,8 @@ import TwitchAdapter from "data/models/twitch/adapter"; export default TwitchAdapter.extend({ query( store, type, query ) { - const url = this.buildURL( type, null, null, "query", query ); - delete query.channel; - query = this.sortQueryParams ? this.sortQueryParams( query ) : query; + const url = this._buildURL( "helix/teams/channel" ); return this.ajax( url, "GET", { data: query } ); - }, - - urlForQuery( query ) { - // use this approach until EmberData ships the new ds-improved-ajax feature - if ( query && query.channel ) { - //noinspection JSCheckFunctionSignatures - return this._buildURL( `kraken/channels/${query.channel}/teams` ); - } - - return this._super( ...arguments ); } }); diff --git a/src/app/data/models/twitch/team/model.js b/src/app/data/models/twitch/team/model.js index 022c50504e..c6cf3d1b75 100644 --- a/src/app/data/models/twitch/team/model.js +++ b/src/app/data/models/twitch/team/model.js @@ -1,26 +1,45 @@ -import { get, computed } from "@ember/object"; +import { computed } from "@ember/object"; import attr from "ember-data/attr"; import Model from "ember-data/model"; -import { hasMany } from "ember-data/relationships"; -export default Model.extend({ - background: attr( "string" ), +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchTeam */ { + // Avoid defining hasMany relationships: + // We want to query the Twitch{Stream,User} data ourselves in the Team{Index,Members}Route, + // so we can query the API in chunks via the infinite scroll mechanism. + // The TwitchTeamSerializer will turn the "users" payload property into a list of user IDs. + /** @type {string[]} */ + users: attr(), + + /** @type {string} */ + background_image_url: attr( "string" ), + /** @type {string} */ banner: attr( "string" ), + /** @type {Date} */ created_at: attr( "date" ), - display_name: attr( "string" ), - info: attr( "string" ), - logo: attr( "string" ), - name: attr( "string" ), + /** @type {Date} */ updated_at: attr( "date" ), - users: hasMany( "twitchChannel", { async: false } ), + /** @type {string} */ + info: attr( "string" ), + /** @type {string} */ + thumbnail_url: attr( "string" ), + /** @type {string} */ + team_name: attr( "string" ), + /** @type {string} */ + team_display_name: attr( "string" ), - title: computed( "name", "display_name", function() { - return get( this, "display_name" ) - || get( this, "name" ); - }) + /** @type {string} */ + title: computed( + "team_name", + "team_display_name", + /** @this {TwitchTeam} */ + function() { + return this.team_display_name || this.team_name; + } + ) }).reopenClass({ - toString() { return "kraken/teams"; } + toString() { return "helix/teams"; } }); diff --git a/src/app/data/models/twitch/team/serializer.js b/src/app/data/models/twitch/team/serializer.js index ef7f6e27a8..42101f1662 100644 --- a/src/app/data/models/twitch/team/serializer.js +++ b/src/app/data/models/twitch/team/serializer.js @@ -1,23 +1,23 @@ import TwitchSerializer from "data/models/twitch/serializer"; -export default TwitchSerializer.extend({ - primaryKey: "name", +const { hasOwnProperty } = {}; - modelNameFromPayloadKey() { - return "twitchTeam"; - }, - attrs: { - users: { deserialize: "records" } +export default TwitchSerializer.extend({ + modelNameFromPayloadKey() { + return "twitch-team"; }, - normalizeSingleResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload = { - [ this.modelNameFromPayloadKey() ]: payload - }; + normalize( modelClass, resourceHash, prop ) { + // we only care about user_id values + resourceHash[ "users" ] = ( resourceHash[ "users" ] /* istanbul ignore next */ || [] ) + .filter( user => user + && typeof user === "object" + && hasOwnProperty.call( user, "user_id" ) + ) + .map( user => user[ "user_id" ] ); - return this._super( store, primaryModelClass, payload, id, requestType ); + return this._super( modelClass, resourceHash, prop ); } }); diff --git a/src/app/data/models/twitch/user-followed/model.js b/src/app/data/models/twitch/user-followed/model.js new file mode 100644 index 0000000000..7f8ff5fd6d --- /dev/null +++ b/src/app/data/models/twitch/user-followed/model.js @@ -0,0 +1,16 @@ +import attr from "ember-data/attr"; +import Model from "ember-data/model"; +import { belongsTo } from "ember-data/relationships"; + + +export default Model.extend( /** @class TwitchUserFollowed */ { + /** @type {TwitchUser} */ + from: belongsTo( "twitch-user", { async: true } ), + /** @type {TwitchUser} */ + to: belongsTo( "twitch-user", { async: true } ), + /** @type {Date} */ + followed_at: attr( "date" ) + +}).reopenClass({ + toString() { return "helix/users/follows"; } +}); diff --git a/src/app/data/models/twitch/user-followed/serializer.js b/src/app/data/models/twitch/user-followed/serializer.js new file mode 100644 index 0000000000..1d60356b20 --- /dev/null +++ b/src/app/data/models/twitch/user-followed/serializer.js @@ -0,0 +1,19 @@ +import TwitchSerializer from "data/models/twitch/serializer"; + + +export default TwitchSerializer.extend({ + modelNameFromPayloadKey() { + return "twitch-user-followed"; + }, + + normalize( modelClass, resourceHash, prop ) { + const { from_id, to_id } = resourceHash; + resourceHash[ this.primaryKey ] = `${from_id}-${to_id}`; + resourceHash[ "from" ] = from_id; + resourceHash[ "to" ] = to_id; + delete resourceHash[ "from_id" ]; + delete resourceHash[ "to_id" ]; + + return this._super( modelClass, resourceHash, prop ); + } +}); diff --git a/src/app/data/models/twitch/user/adapter.js b/src/app/data/models/twitch/user/adapter.js index 73a0464b48..b2ad2de5be 100644 --- a/src/app/data/models/twitch/user/adapter.js +++ b/src/app/data/models/twitch/user/adapter.js @@ -2,26 +2,7 @@ import TwitchAdapter from "data/models/twitch/adapter"; export default TwitchAdapter.extend({ - // automatically turns multiple findRecord calls into a single findMany call coalesceFindRequests: true, - // and uses "login" as query string parameter for the IDs, as defined by the TwitchAdapter - findManyIdString: "login", - - /** - * @param {DS.Store} store - * @param {DS.Model} type - * @param {string} id - * @param {DS.Snapshot} snapshot - * @return {Promise} - */ - findRecord( store, type, id, snapshot ) { - const url = this.buildURL( type, null, snapshot, "findRecord" ); - const data = { - login: id - }; - - return this.ajax( url, "GET", { data: data } ); - }, /** * @param {string} id diff --git a/src/app/data/models/twitch/user/model.js b/src/app/data/models/twitch/user/model.js index 7b36416a3a..6d7a23657a 100644 --- a/src/app/data/models/twitch/user/model.js +++ b/src/app/data/models/twitch/user/model.js @@ -1,18 +1,132 @@ +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import attr from "ember-data/attr"; import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; +import { + ATTR_STREAMS_NAME_CUSTOM, + ATTR_STREAMS_NAME_ORIGINAL, + ATTR_STREAMS_NAME_BOTH +} from "data/models/settings/streams/fragment"; /** - * This model is only being used for mapping channel names to user IDs. - * Users are being looked up via GET /kraken/users?login=:name - * - * The primary key is the user/channel name, so it can be looked up via store.findRecord. - * The original primary key (_id) is used as a key for TwitchChannel/TwitchStream relationships. + * @param {DS.Store} store + * @param {string} id + * @return {Promise} */ -export default Model.extend({ - channel: belongsTo( "twitchChannel", { async: true } ), - stream: belongsTo( "twitchStream", { async: true } ) +export async function getChannelSettings( store, id ) { + /** @type {ChannelSettings|DS.Model} */ + let record; + try { + record = await store.findRecord( "channel-settings", id ); + } catch ( e ) { + record = await store.recordForId( "channel-settings", id ); + } + + const data = record.toJSON(); + store.unloadRecord( record ); + + return data; +} + + +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchUser */ { + /** @type {IntlService} */ + intl: service(), + /** @type {SettingsService} */ + settings: service(), + + /** @type {TwitchChannel} */ + channel: belongsTo( "twitch-channel", { async: true } ), + + /** @type {string} */ + broadcaster_type: attr( "string" ), + /** @type {string} */ + description: attr( "string" ), + /** @type {string} */ + display_name: attr( "string" ), + /** @type {string} */ + login: attr( "string" ), + /** @type {string} */ + offline_image_url: attr( "string" ), + /** @type {string} */ + profile_image_url: attr( "string" ), + /** @type {string} */ + type: attr( "string" ), + /** @type {number} */ + view_count: attr( "number" ), + /** @type {Date} */ + created_at: attr( "date" ), + + + /** @type {boolean} */ + isPartner: computed( + "broadcaster_type", + /** @this {TwitchUser} */ + function() { + const { broadcaster_type } = this; + + return broadcaster_type === "partner" || broadcaster_type === "affiliate"; + } + ), + + /** @type {boolean} */ + hasCustomDisplayName: computed( + "login", + "display_name", + /** @this {TwitchUser} */ + function() { + // in case this computed property was accessed via TwitchStream before its + // TwitchUser relationship promise was resolved, login and display_name will be null + return String( this.login ).toLowerCase() !== String( this.display_name ).toLowerCase(); + } + ), + + /** @type {string} */ + detailedName: computed( + "login", + "display_name", + "hasCustomDisplayName", + "settings.content.streams.name", + /** @this {TwitchUser} */ + function() { + switch ( this.settings.content.streams.name ) { + case ATTR_STREAMS_NAME_CUSTOM: + return this.display_name; + case ATTR_STREAMS_NAME_ORIGINAL: + return this.login; + case ATTR_STREAMS_NAME_BOTH: + return this.hasCustomDisplayName + ? `${this.display_name} (${this.login})` + : this.display_name; + default: + return this.display_name; + } + } + ), + + /** @type {string} */ + titleViewCount: computed( + "intl.locale", + "view_count", + /** @this {TwitchUser} */ + function() { + return this.intl.t( "models.twitch.user.view_count", { count: this.view_count } ); + } + ), + + /** + * Load channel specific settings + * @returns {Promise} + */ + async getChannelSettings() { + const { store, id } = this; + + return await getChannelSettings( store, id ); + } }).reopenClass({ - toString() { return "kraken/users"; } + toString() { return "helix/users"; } }); diff --git a/src/app/data/models/twitch/user/serializer.js b/src/app/data/models/twitch/user/serializer.js index 1f07327705..1de2510072 100644 --- a/src/app/data/models/twitch/user/serializer.js +++ b/src/app/data/models/twitch/user/serializer.js @@ -2,33 +2,14 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ - primaryKey: "id", - modelNameFromPayloadKey() { - return "twitchUser"; - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - payload = { - [ this.modelNameFromPayloadKey() ]: ( payload.users /* istanbul ignore next */ || [] ) - .map( user => ({ - [ this.primaryKey ]: user.name, - channel: user._id, - stream: user._id - }) ) - }; - - return this._super( store, primaryModelClass, payload, id, requestType ); + return "twitch-user"; }, - normalizeFindRecordResponse( store, primaryModelClass, payload, id, requestType ) { - const key = this.modelNameFromPayloadKey(); - payload[ key ] = payload[ key ][0] /* istanbul ignore next */ || null; - - return this._super( store, primaryModelClass, payload, id, requestType ); - }, + normalize( modelClass, resourceHash, prop ) { + // add key for custom channel relationship + resourceHash.channel = resourceHash[ this.primaryKey ]; - normalizeQueryRecordResponse( ...args ) { - return this.normalizeFindRecordResponse( ...args ); + return this._super( modelClass, resourceHash, prop ); } }); diff --git a/src/app/data/transforms/twitch/image.js b/src/app/data/transforms/twitch/image.js new file mode 100644 index 0000000000..84e068f066 --- /dev/null +++ b/src/app/data/transforms/twitch/image.js @@ -0,0 +1,90 @@ +import Transform from "ember-data/transform"; +import { vars as varsConfig } from "config"; + + +const { "image-expiration-time": DEFAULT_EXPIRATION_TIME } = varsConfig; + +const reWidth = /{width}/g; +const reHeight = /{height}/g; + + +/** + * @class TwitchImage + */ +class TwitchImage { + /** + * @param {string} template + * @param {number} width + * @param {number} height + * @param {number} expiration + */ + constructor( template, width, height, expiration ) { + this.template = template; + this.expiration = expiration; + + this.url = template + ? template.replace( reWidth, width ).replace( reHeight, height ) + : null; + this.time = null; + } + + /** + * @return {string} + */ + get current() { + const { url, time } = this; + + return url === null || time === null + ? url + : `${url}?_=${time}`; + } + + /** + * @return {string} + */ + get latest() { + const { url, expiration } = this; + if ( !url || !expiration ) { + return this.url; + } + + const now = Date.now(); + let { time } = this; + if ( time === null || time <= now ) { + time = this.time = now + expiration; + } + + return `${url}?_=${time}`; + } + + toString() { + return this.url; + } +} + + +export default Transform.extend({ + /** + * @param {string} template + * @param {Object} options + * @param {number} options.width + * @param {number} options.height + * @param {(number|undefined)} options.expiration + * @return {TwitchImage} + */ + deserialize( template, options = {} ) { + const { width, height, expiration = DEFAULT_EXPIRATION_TIME } = options; + + return new TwitchImage( template, width, height, expiration ); + }, + + /** + * @param {TwitchImage} twitchImage + * @return {(string|null)} + */ + serialize( twitchImage ) { + return twitchImage + ? `${twitchImage}` + : null; + } +}); diff --git a/src/app/init/initializers/localstorage/initializer.js b/src/app/init/initializers/localstorage/initializer.js index 471bb0f874..a9d4da3fdf 100644 --- a/src/app/init/initializers/localstorage/initializer.js +++ b/src/app/init/initializers/localstorage/initializer.js @@ -2,12 +2,16 @@ import LS from "./localstorage"; import updateNamespaces from "./namespaces"; import updateSettings from "./settings"; import updateChannelSettings from "./channelsettings"; +import updateSearch from "./search"; export default { name: "localstorage", before: "ember-data", + // TODO: finally add schema version numbers to the LocalStorage serializers + // and run upgrades against these numbers + // TODO: Make this initializer more DRY initialize() { try { updateNamespaces( LS ); @@ -30,5 +34,13 @@ export default { } LS.setItem( "channelsettings", JSON.stringify( data ) ); } catch ( e ) {} + + try { + const data = JSON.parse( LS.getItem( "search" ) ); + if ( !data || !data[ "search" ] ) { throw null; } + const { records } = data[ "search" ]; + updateSearch( records ); + LS.setItem( "search", JSON.stringify( data ) ); + } catch ( e ) {} } }; diff --git a/src/app/init/initializers/localstorage/search.js b/src/app/init/initializers/localstorage/search.js new file mode 100644 index 0000000000..4b13b296f7 --- /dev/null +++ b/src/app/init/initializers/localstorage/search.js @@ -0,0 +1,25 @@ +/** @typedef {{id: string, query: string, filter: string, date: string}} SearchSerialized */ + +/** @param {Object} records */ +function removeStreamsFilter( records ) { + const channelsQueries = new Set(); + + for ( const record of Object.values( records ) ) { + const { filter } = record; + if ( filter === "channels" ) { + channelsQueries.add( record.query ); + } else if ( filter === "streams" ) { + if ( !channelsQueries.has( record.query ) ) { + record.filter = "channels"; + } else { + delete records[ record.id ]; + } + } + } +} + + +/** @param {Object} records */ +export default function updateSearch( records ) { + removeStreamsFilter( records ); +} diff --git a/src/app/init/initializers/localstorage/settings.js b/src/app/init/initializers/localstorage/settings.js index 3cbaef34cf..6838947fe4 100644 --- a/src/app/init/initializers/localstorage/settings.js +++ b/src/app/init/initializers/localstorage/settings.js @@ -133,7 +133,9 @@ function fixAttributes( settings ) { gui.minimizetotray = !!gui.minimizetotray; } if ( hasOwnProperty.call( gui, "homepage" ) ) { - if ( + if ( gui.homepage === "/featured" ) { + gui.homepage = "/streams"; + } else if ( gui.homepage === "/user/following" || gui.homepage === "/user/subscriptions" || gui.homepage === "/user/hostedStreams" diff --git a/src/app/init/instance-initializers/routing.js b/src/app/init/instance-initializers/routing.js index a09d4af780..4b31e3c466 100644 --- a/src/app/init/instance-initializers/routing.js +++ b/src/app/init/instance-initializers/routing.js @@ -113,7 +113,7 @@ function initialize( application ) { */ homepage( shouldReplace ) { return this[ shouldReplace ? "replaceWith" : "transitionTo" ]( - this.settings.content.gui.homepage || "/featured" + this.settings.content.gui.homepage || "/streams" ); }, diff --git a/src/app/locales/de/components.yml b/src/app/locales/de/components.yml index 88fc8f6371..14377ecd86 100644 --- a/src/app/locales/de/components.yml +++ b/src/app/locales/de/components.yml @@ -1,7 +1,5 @@ channel-button: title: "{name} folgen" -channel-item: - game-missing: kein Spiel ausgewählt documentation-link: title: Dokumentation online durchlesen drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Durchsuchen - featured: Vorgestellt games: Spiele streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Chat öffnen quick-bar-homepage: title: Als Startseite setzen -quick-bar-random-stream: - title: Zufälligen Stream starten search-bar: placeholder: Suchen filter: Ergebnisse filtern @@ -76,7 +71,6 @@ settings-submit: share-channel: title: URL des Kanals in die Zwischenablage kopieren stream-item: - fps: "{fps} fps" details: show: Streamdetails anzeigen lock: Detailansicht sperren diff --git a/src/app/locales/de/models.yml b/src/app/locales/de/models.yml index 180c442cf3..953ee5ae25 100644 --- a/src/app/locales/de/models.yml +++ b/src/app/locales/de/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {Eine Person folgt} - other {# Leute folgen} - } - views: | + user: + view_count: | {count, plural, one {Ein Aufruf} other {# Aufrufe} } stream: - created-at: - less-than-24h: "Online seit {created_at, time, medium}" - more-than-24h: "Online seit {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Online seit {started_at, time, medium}" + more-than-24h: "Online seit {started_at, time, long}" + viewer_count: | {count, plural, one {Eine Person schaut zu} other {# Leute schauen zu} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online seit {started_at, time, medium}" + more-than-24h: "Online seit {started_at, time, long}" diff --git a/src/app/locales/de/routes.yml b/src/app/locales/de/routes.yml index a9e265846a..7de3b68dc3 100644 --- a/src/app/locales/de/routes.yml +++ b/src/app/locales/de/routes.yml @@ -26,10 +26,6 @@ about: header: Übersetzungen dependencies: header: Drittanwendungen -featured: - header: Vorgestellte Kanäle - empty: Die Liste der vorgestellten Kanäle konnte nicht geladen werden! - summary: Zur Zeit schauen {viewers} Leute {channels} Livestreams zu! games: header: Top-Spiele empty: Die Liste der Top-Spiele ist leer. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Sprache - followers: - title: Follower - data: "{followers} ({day} pro Tag)" - views: + view-count: title: Aufrufe data: "{views} ({day} pro Tag)" content: title: Inhalt mature: Nicht geeignet für Kinder ffa: Keine Altersbeschränkung - format: - title: Format - data: "{resolution} @ {fps}fps" delay: title: Verzögerung data: | @@ -145,27 +135,18 @@ user: header: Gefolgte Streams empty: text: Die Liste der gefolgten Streams ist leer. - featured: Vorgestellte Streams anschauen - streams: Top-Streams anschauen + streams: Beliebte Streams anschauen followedChannels: header: Gefolgte Kanäle - buttons: - sort-by-followed: Nach Folge-Datum sortieren - sort-by-broadcast: Nach letztem Broadcast sortieren - sort-by-login: Nach letztem Login sortieren - sort-desc: In absteigender Reihe sortieren - sort-asc: In aufsteigender Reihe sortieren empty: Die Liste der gefolgten Kanäle ist leer. search: header: all: Alle Suchergebnisse games: "Spiel-Suchergebnisse: " channels: "Kanal-Suchergebnisse: " - streams: "Stream-Suchergebnisse: " subheader: games: Spiele channels: Kanäle - streams: Streams empty: Deine Suchanfrage lieferte keine Ergebnisse... watching: header: Du schaust @@ -175,5 +156,5 @@ watching: empty: header: Alle Streams wurden geschlossen next: Was tun als nächstes? - featured: Vorgestellte Streams anschauen + streams: Beliebte Streams anschauen followed: Gefolgte Streams anschauen diff --git a/src/app/locales/en/components.yml b/src/app/locales/en/components.yml index 5530b72fac..fe73550eb2 100644 --- a/src/app/locales/en/components.yml +++ b/src/app/locales/en/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Follow {name}" -channel-item: - game-missing: no game set yet documentation-link: title: Read the documentation online drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Browse - featured: Featured games: Games streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Open chat quick-bar-homepage: title: Set as homepage -quick-bar-random-stream: - title: Launch random stream search-bar: placeholder: Search filter: Filter results @@ -76,7 +71,6 @@ settings-submit: share-channel: title: Copy channel url to clipboard stream-item: - fps: "{fps} fps" details: show: Show stream details lock: Lock details view diff --git a/src/app/locales/en/models.yml b/src/app/locales/en/models.yml index 0b62fcf2d8..e61234e1d2 100644 --- a/src/app/locales/en/models.yml +++ b/src/app/locales/en/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {One person is following} - other {# people are following} - } - views: | + user: + view_count: | {count, plural, one {One channel view} other {# channel views} } stream: - created-at: - less-than-24h: "Online since {created_at, time, medium}" - more-than-24h: "Online since {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Online since {started_at, time, medium}" + more-than-24h: "Online since {started_at, time, long}" + viewer_count: | {count, plural, one {One person is watching} other {# people are watching} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online since {started_at, time, medium}" + more-than-24h: "Online since {started_at, time, long}" diff --git a/src/app/locales/en/routes.yml b/src/app/locales/en/routes.yml index d180ec71a9..7d374a07ee 100644 --- a/src/app/locales/en/routes.yml +++ b/src/app/locales/en/routes.yml @@ -26,10 +26,6 @@ about: header: Translations dependencies: header: Third party applications -featured: - header: Featured Channels - empty: The featured channels list could not be loaded! - summary: There are {viewers} people watching {channels} live streams right now! games: header: Top Games empty: The returned list of top games is empty. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Language - followers: - title: Followers - data: "{followers} ({day} per day)" - views: + view-count: title: Views data: "{views} ({day} per day)" content: title: Content mature: Inappropriate for children ffa: No age restrictions - format: - title: Format - data: "{resolution} @ {fps}fps" delay: title: Delay data: | @@ -145,27 +135,18 @@ user: header: Followed Streams empty: text: The returned list of followed streams is empty. - featured: Watch currently featured streams - streams: Watch top streams + streams: Watch popular streams followedChannels: header: Followed Channels - buttons: - sort-by-followed: Sort by date followed - sort-by-broadcast: Sort by last broadcast - sort-by-login: Sort by last login - sort-desc: Sort in descending order - sort-asc: Sort in ascending order empty: The returned list of followed channels is empty. search: header: all: All search results games: "Game search results: " channels: "Stream search results: " - streams: "Stream search results: " subheader: games: Games channels: Channels - streams: Streams empty: Your query didn't match anything... watching: header: You're watching @@ -175,5 +156,5 @@ watching: empty: header: All streams have been closed next: What to do next? - featured: Watch currently featured streams + streams: Watch popular streams followed: Watch your followed streams diff --git a/src/app/locales/es/components.yml b/src/app/locales/es/components.yml index 455c958e4d..3c021461d4 100644 --- a/src/app/locales/es/components.yml +++ b/src/app/locales/es/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Seguir a {name}" -channel-item: - game-missing: no hay juego establecido documentation-link: title: Leer la documentación online drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Navegar - featured: Destacados games: Juegos streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Abrir chat quick-bar-homepage: title: Establecer página de inicio -quick-bar-random-stream: - title: Iniciar stream aleatorio search-bar: placeholder: Buscar filter: Filtrar resultados @@ -76,7 +71,6 @@ settings-submit: share-channel: title: Copiar url del canal al portapapeles stream-item: - fps: "{fps} fps" details: show: Mostrar detalles del stream lock: Bloquear vista de detalles diff --git a/src/app/locales/es/models.yml b/src/app/locales/es/models.yml index 607f0fa4ee..f42e581a13 100644 --- a/src/app/locales/es/models.yml +++ b/src/app/locales/es/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {Una persona está siguiendo} - other {# personas están siguiendo} - } - views: | + user: + view_count: | {count, plural, one {Una visita del canal} other {# visitas del canal} } stream: - created-at: - less-than-24h: "Online desde las {created_at, time, medium}" - more-than-24h: "Online desde las {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Online desde las {started_at, time, medium}" + more-than-24h: "Online desde las {started_at, time, long}" + viewer_count: | {count, plural, one {Una persona está viendo} other {# personas están viendo} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online desde las {started_at, time, medium}" + more-than-24h: "Online desde las {started_at, time, long}" diff --git a/src/app/locales/es/routes.yml b/src/app/locales/es/routes.yml index 3f0db5cf5e..e0c7014d07 100644 --- a/src/app/locales/es/routes.yml +++ b/src/app/locales/es/routes.yml @@ -26,10 +26,6 @@ about: header: Traducciones dependencies: header: Aplicaciones de terceros -featured: - header: Canales destacados - empty: ¡No se pudo cargar la lista de canales destacados! - summary: Hay {viewers} personas viendo {channels} streams ahora mismo! games: header: Top Juegos empty: La lista devuelta de los tops juegos está vacía. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Idioma - followers: - title: Seguidores - data: "{followers} ({day} por día)" - views: + view-count: title: Visitas data: "{views} ({day} por día)" content: title: Contenido mature: Inapropiado para niños ffa: Sin restricciones de edad - format: - title: Formato - data: "{resolution} @ {fps}fps" delay: title: Delay data: | @@ -145,35 +135,26 @@ user: header: Streams Seguidos empty: text: La lista devuelta de streams seguidos está vacía. - featured: Ver streams destacados actualmente streams: Ver top streams followedChannels: header: Canales Seguidos - buttons: - sort-by-followed: Ordenar por fecha seguida - sort-by-broadcast: Ordenar por última transmisión - sort-by-login: Ordenar por último inicio de sesión - sort-desc: Ordenar en orden descendente - sort-asc: Ordenar en orden ascendente empty: La lista devuelta de canales seguidos está vacía. search: header: all: Todos los resultados de la búsqueda games: "Resultados de la búsqueda de juegos: " channels: "Resultados de la búsqueda de canales: " - streams: "Resultados de la búsqueda de streams: " subheader: games: Juegos channels: Canales - streams: Streams empty: Tu consulta no coincidió con nada... watching: header: Estás viendo buttons: - open: Abrir diálogo + open: Abrir diálogo close: Cerrar stream empty: header: Todos los streams han sido cerrados next: ¿Qué hacer ahora? - featured: Ver streams destacados actualmente + streams: Ver top streams followed: Ver tus streams seguidos diff --git a/src/app/locales/fr/components.yml b/src/app/locales/fr/components.yml index 2d515734b5..88a8b95746 100644 --- a/src/app/locales/fr/components.yml +++ b/src/app/locales/fr/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Suivre {name}" -channel-item: - game-missing: pas de jeu configuré documentation-link: title: Lire la documentation en ligne drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Parcourir - featured: En avant games: Jeux streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Ouvrir le chat quick-bar-homepage: title: Définir comme page d'accueil -quick-bar-random-stream: - title: Lancer un stream aléatoire search-bar: placeholder: Chercher filter: Filtrer les résultats @@ -76,7 +71,6 @@ settings-submit: share-channel: title: Copier l'url de la chaîne dans le presse-papiers stream-item: - fps: "{fps} fps" details: show: Voir les détails du stream lock: Verrouiller la vue de détails diff --git a/src/app/locales/fr/models.yml b/src/app/locales/fr/models.yml index e62f2e6b1c..5769fbb3f5 100644 --- a/src/app/locales/fr/models.yml +++ b/src/app/locales/fr/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {Une personne suit cette chaîne} - other {# personnes suivent cette chaîne} - } - views: | + user: + view_count: | {count, plural, one {Une vue de la chaîne} other {# vues de la chaîne} } stream: - created-at: - less-than-24h: "En ligne depuis le {created_at, time, medium}" - more-than-24h: "En ligne depuis le {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "En ligne depuis le {started_at, time, medium}" + more-than-24h: "En ligne depuis le {started_at, time, long}" + viewer_count: | {count, plural, one {Une personne est en train de regarder} other {# personnes sont en train de regarder} } + search-channel: + started-at: + offline: "Hors ligne" + less-than-24h: "En ligne depuis le {started_at, time, medium}" + more-than-24h: "En ligne depuis le {started_at, time, long}" diff --git a/src/app/locales/fr/routes.yml b/src/app/locales/fr/routes.yml index 4adad8b0c1..7a2be0d5ce 100644 --- a/src/app/locales/fr/routes.yml +++ b/src/app/locales/fr/routes.yml @@ -26,10 +26,6 @@ about: header: Traductions dependencies: header: Application tierces -featured: - header: Chaînes mises en avant - empty: La liste des chaînes mises en avant n'a pas pu être chargée ! - summary: Il y actuellement {viewers} personnes en train de regarder {channels} streams ! games: header: Top Jeux empty: La liste des top jeux retournée est vide. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Langue - followers: - title: Followers - data: "{followers} ({day} par jour)" - views: + view-count: title: Vues data: "{views} ({day} par jour)" content: title: Contenu mature: Inadapté aux enfants ffa: Pas de restriction d'âge - format: - title: Format - data: "{resolution} @ {fps}fps" delay: title: Délai data: | @@ -145,27 +135,18 @@ user: header: Stream suivis empty: text: La liste retournée des streams est vide - featured: Regarder les flux actuellement mis en avant streams: Regarder les top streams followedChannels: header: Chaînes suivies - buttons: - sort-by-followed: Trier par date de suivi - sort-by-broadcast: Trier par la date de dernière diffusion - sort-by-login: Trier par la date de dernière connexion - sort-desc: Trier par ordre décroissant - sort-asc: Trier par ordre croissant empty: La liste retournée des chaînes suivies est vide. search: header: all: Tous les résultats de la recherche games: "Résultat de la recherche de jeux : " channels: "Résultat de la recherche de chaînes : " - streams: "Résultat de la recherche de streams : " subheader: games: Jeux channels: Chaînes - streams: Streams empty: Votre requête n'a renvoyé aucun résultat... watching: header: Vous regardez @@ -175,5 +156,5 @@ watching: empty: header: Tous les streams ont été fermés next: Que faire ensuite ? - featured: Regarder les streams actuellement mis en avant + streams: Regarder les top streams followed: Regardez vos streams suivis diff --git a/src/app/locales/it/components.yml b/src/app/locales/it/components.yml index 33a37d9c69..3721e5d852 100644 --- a/src/app/locales/it/components.yml +++ b/src/app/locales/it/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Segui {name}" -channel-item: - game-missing: Nessun gioco ancora impostato documentation-link: title: Leggi la documentazione online drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Naviga - featured: In primo piano games: Giochi streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Apri chat quick-bar-homepage: title: Imposta come pagina iniziale -quick-bar-random-stream: - title: Avvia stream casuale search-bar: placeholder: Cerca filter: Filtra risultati @@ -49,7 +44,7 @@ settings-channel-item: erase: Non settato confirmation: Annullare le impostazioni personalizzate? confirm: Sì - decline: No + decline: No settings-hotkey: empty: Nessun tasto di scelta rapida impostato edit: Personalizza questo tasto di scelta rapida @@ -74,7 +69,6 @@ settings-submit: share-channel: title: Copia l'URL del canale negli appunti stream-item: - fps: "{fps} fps" details: show: Mostra dettagli streaming lock: Blocca la visualizzazione dei dettagli diff --git a/src/app/locales/it/models.yml b/src/app/locales/it/models.yml index 0d6f48511d..5ad97f8e7f 100644 --- a/src/app/locales/it/models.yml +++ b/src/app/locales/it/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {Una persona sta seguendo} - other {# persone stanno seguendo} - } - views: | + user: + view_count: | {count, plural, one {Visualizzazione di un canale} other {# visualizzazioni canale} } stream: - created-at: - less-than-24h: "Online da {created_at, time, medium}" - more-than-24h: "Online da {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Online da {started_at, time, medium}" + more-than-24h: "Online da {started_at, time, long}" + viewer_count: | {count, plural, one {Una persona sta guardando} other {# persone stanno guardando} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online da {started_at, time, medium}" + more-than-24h: "Online da {started_at, time, long}" diff --git a/src/app/locales/it/routes.yml b/src/app/locales/it/routes.yml index 646733e98a..a544a199ff 100644 --- a/src/app/locales/it/routes.yml +++ b/src/app/locales/it/routes.yml @@ -26,10 +26,6 @@ about: header: Traduzioni dependencies: header: Applicazioni di terze parti -featured: - header: Canali in primo piano - empty: Impossibile caricare l'elenco dei canali in primo piano! - summary: Ci sono {viewers} persone che stanno guardando {channels} live streaming in questo momento! games: header: Migliori giochi empty: L'elenco restituito dei migliori giochi è vuoto. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Lingua - followers: - title: Followers - data: "{followers} ({day} per day)" - views: + view-count: title: Visualizzazioni data: "{views} ({day} per day)" content: title: Contenuto mature: Inappropriato per i bambini ffa: Nessun limite di età - format: - title: Formato - data: "{resolution} @ {fps}fps" delay: title: Ritardo data: | @@ -145,27 +135,18 @@ user: header: Stream seguiti empty: text: L'elenco restituito degli stream seguiti è vuoto. - featured: Guarda gli stream attualmente in primo piano streams: Guarda i migliori stream followedChannels: header: Canali seguiti - buttons: - sort-by-followed: Ordina per data seguita - sort-by-broadcast: Ordina per ultima trasmissione - sort-by-login: Ordina per ultimo accesso - sort-desc: Ordina in ordine decrescente - sort-asc: Ordina in ordine crescente empty: L'elenco restituito dei canali seguiti è vuoto. search: header: all: Tutti i risultati della ricerca games: "Risultati dei giochi cercati: " channels: "Risultati dei canali cercati: " - streams: "Risultati dei flussi cercati: " subheader: games: Giochi channels: Canali - streams: Flussi empty: La tua richiesta non corrisponde a nulla... watching: header: Stai guardando @@ -175,5 +156,5 @@ watching: empty: header: Tutti i flussi sono stati chiusi next: Cosa fare dopo? - featured: Guarda gli stream attualmente in primo piano + streams: Guarda i migliori stream followed: Guarda i tuoi stream seguiti diff --git a/src/app/locales/pt-br/components.yml b/src/app/locales/pt-br/components.yml index 4f47a5472f..31eee25b21 100644 --- a/src/app/locales/pt-br/components.yml +++ b/src/app/locales/pt-br/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Seguir {name}" -channel-item: - game-missing: nenhum jogo definido documentation-link: title: Ler a documentação online drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Navegar - featured: Em destaque games: Jogos streams: Streams followed: @@ -33,8 +30,6 @@ open-chat: title: Abrir chat quick-bar-homepage: title: Definir como página principal -quick-bar-random-stream: - title: Abrir um stream aleatório search-bar: placeholder: Procurar filter: Filtrar resultados @@ -76,7 +71,6 @@ settings-submit: share-channel: title: Copiar URL do canal para a área de cópia stream-item: - fps: "{fps} fps" details: show: Mostrar detalhes da stream lock: Travar a visualização de detalhes diff --git a/src/app/locales/pt-br/models.yml b/src/app/locales/pt-br/models.yml index e53d249730..39db8975e4 100644 --- a/src/app/locales/pt-br/models.yml +++ b/src/app/locales/pt-br/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {Uma pessoa está seguindo} - other {# pessoas estão seguindo} - } - views: | + user: + view_count: | {count, plural, one {Apenas uma visualização} other {# visualizações} } stream: - created-at: - less-than-24h: "Online desde {created_at, time, medium}" - more-than-24h: "Online desde {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Online desde {started_at, time, medium}" + more-than-24h: "Online desde {started_at, time, long}" + viewer_count: | {count, plural, one {Uma pessoa está assistindo} other {# estão assistindo} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online desde {started_at, time, medium}" + more-than-24h: "Online desde {started_at, time, long}" diff --git a/src/app/locales/pt-br/routes.yml b/src/app/locales/pt-br/routes.yml index 625703d16d..12cb47eb3e 100644 --- a/src/app/locales/pt-br/routes.yml +++ b/src/app/locales/pt-br/routes.yml @@ -26,10 +26,6 @@ about: header: Traduções dependencies: header: Aplicativos de terceiros -featured: - header: Canais em destaque - empty: A lista de canais em destaque não pôde ser carregado! - summary: Há {viewers} pessoas assistindo {channels} streams agora! games: header: Jogos mais vistos empty: A lista de jogos mais vistos está vazia. @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: Idioma - followers: - title: Seguidores - data: "{followers} ({day} por dia)" - views: + view-count: title: visualizações data: "{views} ({day} por dia)" content: title: Conteúdo mature: Inapropriado para crianças ffa: Sem restrição de idade - format: - title: Formato - data: "{resolution} @ {fps}fps" delay: title: Atraso data: | @@ -145,27 +135,18 @@ user: header: Streams seguidos empty: text: A lista de streams seguidos está vazia. - featured: Watch currently featured streams - streams: Watch top streams + streams: Assista às principais streams followedChannels: header: Canais seguidos - buttons: - sort-by-followed: Ordenar pela data (seguido) - sort-by-broadcast: Ordenar pela última transmissão - sort-by-login: Ordenar pelo último login - sort-desc: Ordenar por ordem descendente - sort-asc: Ordenar por ordem ascendente empty: A lista de canais seguidos está vazia. search: header: all: Todas os resultados buscados games: "Resultado de jogos buscados: " channels: "Resultado de canais buscados: " - streams: "Resultado de stream buscados: " subheader: games: Jogos channels: Canais - streams: Streams empty: Sua requisição não encontrou nada... watching: header: Você está assistindo @@ -175,5 +156,5 @@ watching: empty: header: Todas as streams foram fechadas next: O que fazer a seguir? - featured: Assistir as streams em destaque + streams: Assista às principais streams followed: Assistir as streams que você segue diff --git a/src/app/locales/ru/components.yml b/src/app/locales/ru/components.yml index 6dca6b8e84..408b5da491 100644 --- a/src/app/locales/ru/components.yml +++ b/src/app/locales/ru/components.yml @@ -1,7 +1,5 @@ channel-button: title: "Отслеживать {name}" -channel-item: - game-missing: Игра не указана documentation-link: title: Читать документацию на сайте drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: Обзор - featured: Рекомендации games: Игры streams: Стримы followed: @@ -33,8 +30,6 @@ open-chat: title: Открыть чат quick-bar-homepage: title: Сделать текущий раздел стартовым -quick-bar-random-stream: - title: Запустить случайный стрим search-bar: placeholder: Поиск filter: "Фильтр результатов:" @@ -76,7 +71,6 @@ settings-submit: share-channel: title: Копировать в буфер обмена ссылку на канал stream-item: - fps: "{fps} fps" details: show: Показать подробности lock: Закрепить подробности diff --git a/src/app/locales/ru/models.yml b/src/app/locales/ru/models.yml index 46ab79e98c..7bea15f78f 100644 --- a/src/app/locales/ru/models.yml +++ b/src/app/locales/ru/models.yml @@ -1,13 +1,6 @@ twitch: - channel: - followers: | - {count, plural, - one {# человек подписан} - few {# человека подписано} - many {# человек подписано} - other {Подписчиков: #} - } - views: | + user: + view_count: | {count, plural, one {# просмотр} few {# просмотра} @@ -15,13 +8,18 @@ twitch: other {Просмотров: #} } stream: - created-at: - less-than-24h: "Запущен в {created_at, time, medium}" - more-than-24h: "Запущен в {created_at, time, long}" - viewers: | + started-at: + less-than-24h: "Запущен в {started_at, time, medium}" + more-than-24h: "Запущен в {started_at, time, long}" + viewer_count: | {count, plural, one {# зритель} few {# зрителя} many {# зрителей} other {Зрителей: #} } + search-channel: + started-at: + offline: "не в сети" + less-than-24h: "Запущен в {started_at, time, medium}" + more-than-24h: "Запущен в {started_at, time, long}" diff --git a/src/app/locales/ru/routes.yml b/src/app/locales/ru/routes.yml index cf9d02e203..919676faa2 100644 --- a/src/app/locales/ru/routes.yml +++ b/src/app/locales/ru/routes.yml @@ -26,10 +26,6 @@ about: header: "Переводы" dependencies: header: "Сторонние приложения/библиотеки" -featured: - header: "Рекомендованные каналы" - empty: "Не удалось загрузить список рекомендованных каналов!" - summary: "Прямо сейчас {viewers} зрителей смотрят {channels} стримов!" games: header: "Популярные игры" empty: "Получен пустой список игр." @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: "Язык" - followers: - title: "Подписчики" - data: "{followers} ({day} в день)" - views: + view-count: title: "Просмотры" data: "{views} ({day} в день)" content: title: "Содержимое" mature: "Не подходит для детей" ffa: "Нет ограничений по возрасту" - format: - title: "Формат" - data: "{resolution} @ {fps}fps" delay: title: "Задержка" data: | @@ -147,27 +137,18 @@ user: header: "Отслеживаемые стримы" empty: text: "Нет стримов" - featured: "Смотреть рекомендуемые в настоящее время стримы" streams: "Смотрите лучшие стримы" followedChannels: header: "Отслеживаемые каналы" - buttons: - sort-by-followed: "Сортировать по дате подписки" - sort-by-broadcast: "Сортировать по последней трансляции" - sort-by-login: "Сортировать по последнему входу" - sort-desc: "Сортировать по убыванию" - sort-asc: "Сортировать по возрастанию" empty: "Нет отслеживаемых каналов" search: header: all: "Все результаты поиска:" games: "Результат поиска игр:" channels: "Результат поиска каналов:" - streams: "Результат поиска стримов:" subheader: games: "Игры" channels: "Каналы" - streams: "Стримы" empty: "Нет результатов по Вашему запросу..." watching: header: "Вы смотрите" @@ -177,5 +158,5 @@ watching: empty: header: "Все стримы закрыты" next: "Что делать дальше?" - featured: "Смотреть рекомендуемые в настоящее время стримы" + streams: "Смотрите лучшие стримы" followed: "Смотреть отслеживаемые стримы" diff --git a/src/app/locales/zh-tw/components.yml b/src/app/locales/zh-tw/components.yml index fa20d19202..a1e3176efb 100644 --- a/src/app/locales/zh-tw/components.yml +++ b/src/app/locales/zh-tw/components.yml @@ -1,7 +1,5 @@ channel-button: title: "追隨 {name}" -channel-item: - game-missing: 尚未設定遊戲 documentation-link: title: 檢視線上文件 drop-down-selection: @@ -18,7 +16,6 @@ infinite-scroll: main-menu: browse: header: 瀏覽 - featured: 精選 games: 遊戲 streams: 實況中 followed: @@ -33,8 +30,6 @@ open-chat: title: 開啟聊天室 quick-bar-homepage: title: 設為首頁 -quick-bar-random-stream: - title: 開啟隨機實況 search-bar: placeholder: 搜尋 filter: 過濾器 @@ -76,7 +71,6 @@ settings-submit: share-channel: title: 複製頻道網址 stream-item: - fps: "{fps} fps" details: show: 顯示頻道資訊 lock: 鎖定詳細資訊 diff --git a/src/app/locales/zh-tw/models.yml b/src/app/locales/zh-tw/models.yml index 33899c351f..b0778728a5 100644 --- a/src/app/locales/zh-tw/models.yml +++ b/src/app/locales/zh-tw/models.yml @@ -1,21 +1,21 @@ twitch: - channel: - followers: | - {count, plural, - one {有 1 位追蹤者} - other {有 # 位追蹤者} - } - views: | + user: + view_count: | {count, plural, one {總觀看次數: 1 } other {總觀看次數: #} } stream: - created-at: - less-than-24h: "從 {created_at, time, medium} 開始" - more-than-24h: "從 {created_at, time, long} 開始" - viewers: | + started-at: + less-than-24h: "從 {started_at, time, medium} 開始" + more-than-24h: "從 {started_at, time, long} 開始" + viewer_count: | {count, plural, one {目前有 1 位觀眾} other {目前有 # 位觀眾} } + search-channel: + started-at: + offline: "離線" + less-than-24h: "從 {started_at, time, medium} 開始" + more-than-24h: "從 {started_at, time, long} 開始" diff --git a/src/app/locales/zh-tw/routes.yml b/src/app/locales/zh-tw/routes.yml index ea6677a6ac..e5763e5bbf 100644 --- a/src/app/locales/zh-tw/routes.yml +++ b/src/app/locales/zh-tw/routes.yml @@ -26,10 +26,6 @@ about: header: 翻譯 dependencies: header: 第三方元件 -featured: - header: 精選頻道 - empty: 精選頻道無法載入! - summary: 有 {viewers} 位觀眾正在收看 {channels} 個頻道實況! games: header: 熱門遊戲 empty: 無熱門遊戲 @@ -53,19 +49,13 @@ channel: format: "{created_at, date, long}" language: title: 語言 - followers: - title: 追蹤者 - data: "{followers} ({day}人/日)" - views: + view-count: title: 觀看次數 data: "{views} ({day}/日)" content: title: 內容 mature: 頻道僅適合成人觀看 ffa: 無年齡限制 - format: - title: 格式 - data: "{resolution} @ {fps}fps" delay: title: 延遲 data: | @@ -145,27 +135,18 @@ user: header: 追蹤中實況 empty: text: 目前無追蹤中實況 - featured: 觀看目前的精選實況 streams: 觀看熱門實況 followedChannels: header: 已追隨的頻道 - buttons: - sort-by-followed: 以追隨日期排序 - sort-by-broadcast: 以最後開始實況排序 - sort-by-login: 以最後登入排序 - sort-desc: 降冪排序 - sort-asc: 升冪排序 empty: 目前無追隨頻道 search: header: all: "搜尋結果: " games: "遊戲搜尋結果: " channels: "頻道搜尋結果: " - streams: "實況搜尋結果: " subheader: games: 遊戲 channels: 頻道 - streams: 實況 empty: 查無資訊 watching: header: 正在收看 @@ -175,5 +156,5 @@ watching: empty: header: 所有實況已關閉 next: 下一步? - featured: 觀看目前精選實況 + streams: 觀看熱門實況 followed: 觀看已追隨實況 diff --git a/src/app/router.js b/src/app/router.js index 25236f1186..6f37768d5a 100644 --- a/src/app/router.js +++ b/src/app/router.js @@ -4,12 +4,11 @@ import EmberRouter from "@ember/routing/router"; export default EmberRouter.extend().map(function() { this.route( "watching" ); this.route( "search" ); - this.route( "featured" ); this.route( "games", function() { - this.route( "game", { path: "/:game" } ); + this.route( "game", { path: "/:game_id" } ); }); this.route( "streams" ); - this.route( "channel", { path: "/channel/:channel" }, function() { + this.route( "channel", { path: "/channel/:user_id" }, function() { this.route( "teams" ); this.route( "settings" ); }); @@ -18,7 +17,7 @@ export default EmberRouter.extend().map(function() { this.route( "followedStreams" ); this.route( "followedChannels" ); }); - this.route( "team", { path: "/team/:team" }, function() { + this.route( "team", { path: "/team/:team_id" }, function() { this.route( "members" ); this.route( "info" ); }); diff --git a/src/app/services/auth/service.js b/src/app/services/auth/service.js index f86632dee5..fe69867de3 100644 --- a/src/app/services/auth/service.js +++ b/src/app/services/auth/service.js @@ -1,7 +1,8 @@ -import { set, setProperties, computed } from "@ember/object"; +import { set, setProperties } from "@ember/object"; import Evented from "@ember/object/evented"; import { default as Service, inject as service } from "@ember/service"; import { twitch as twitchConfig } from "config"; +import { randomBytes } from "crypto"; import HttpServer from "utils/node/http/HttpServer"; import OAuthResponseRedirect from "./oauth-redirect.html"; @@ -23,6 +24,11 @@ const { const reToken = /^[a-z\d]{30}$/i; +// Twitch splits up scope names like this +const SEP_SCOPES_OAUTH = " "; +// This separator is used for backwards compatibility +const SEP_SCOPES_STORAGE = "+"; + export default Service.extend( Evented, /** @class AuthService */ { /** @type {NwjsService} */ @@ -35,15 +41,15 @@ export default Service.extend( Evented, /** @class AuthService */ { /** @type {HttpServer} */ server: null, - - url: computed(function() { + buildOAuthUrl( state ) { const redirect = redirecturi.replace( "{server-port}", String( serverport ) ); return baseuri .replace( "{client-id}", clientid ) .replace( "{redirect-uri}", encodeURIComponent( redirect ) ) - .replace( "{scope}", expectedScopes.join( "+" ) ); - }), + .replace( "{scope}", expectedScopes.join( SEP_SCOPES_OAUTH ) ) + .replace( "{state}", state ); + }, async autoLogin() { @@ -62,7 +68,7 @@ export default Service.extend( Evented, /** @class AuthService */ { // then perform auto-login afterwards await this.login( access_token, true ) - .catch( /* istanbul ignore next */ () => {} ); + .catch( new Function() ); }, async _loadSession() { @@ -89,6 +95,7 @@ export default Service.extend( Evented, /** @class AuthService */ { if ( this.server ) { throw new Error( "An OAuth response server is already running" ); } + const state = randomBytes( 16 ).toString( "hex" ); return new Promise( ( resolve, reject ) => { const server = new HttpServer( serverport, 1000 ); @@ -100,10 +107,8 @@ export default Service.extend( Evented, /** @class AuthService */ { }); server.onRequest( "GET", "/token", async ( req, res ) => { - const { access_token, scope } = req.url.query; - // validate token and scope and keep the request open - await this._validateOAuthResponse( access_token, scope ) + await this._validateOAuthResponse( req.url.query, state ) .then( () => { res.end( "OK" ); process.nextTick( resolve ); @@ -118,7 +123,8 @@ export default Service.extend( Evented, /** @class AuthService */ { // open auth url in web browser try { - this.nwjs.openBrowser( this.url ); + const url = this.buildOAuthUrl( state ); + this.nwjs.openBrowser( url ); } catch ( err ) { reject( err ); } @@ -139,17 +145,26 @@ export default Service.extend( Evented, /** @class AuthService */ { /** * Validate the OAuth response after a login attempt - * @param {string} token - * @param {string} scope + * @param {Object} query + * @param {string} query.token_type + * @param {string} query.access_token + * @param {string} query.scope + * @param {string} query.state + * @param {string} expectedState * @returns {Promise} */ - async _validateOAuthResponse( token, scope ) { - // check the returned token and validate scopes - if ( !reToken.test( token ) || !this._validateScope( String( scope ).split( " " ) ) ) { + async _validateOAuthResponse( query, expectedState ) { + const { token_type, access_token, scope, state } = query; + if ( + token_type !== "bearer" + || !reToken.test( access_token ) + || !this._validateScope( String( scope ).split( SEP_SCOPES_OAUTH ) ) + || state !== expectedState + ) { throw new Error( "OAuth token validation error" ); } - return await this.login( token, false ); + return await this.login( access_token, false ); }, /** @@ -164,6 +179,10 @@ export default Service.extend( Evented, /** @class AuthService */ { set( session, "isPending", true ); try { + if ( isAutoLogin ) { + this._validateSessionScope(); + } + // tell the twitch adapter to use the token from now on this._updateAdapter( token ); @@ -172,11 +191,11 @@ export default Service.extend( Evented, /** @class AuthService */ { if ( !isAutoLogin ) { // save auth record if this was no auto login - await this._sessionSave( token, record ); + await this._sessionSave( token ); } // also don't forget to set the user_name on the auth record (regular properties) - const { user_id, user_name } = record; + const { id: user_id, login: user_name } = record; setProperties( session, { user_id, user_name } ); success = true; @@ -194,40 +213,46 @@ export default Service.extend( Evented, /** @class AuthService */ { /** * Adapter was updated. Now check if the access token is valid. - * @returns {Promise} + * @returns {Promise} */ async _validateSession() { - /** @type {TwitchRoot} */ - const twitchRoot = await this.store.queryRecord( "twitch-root", {} ); - const { valid, user_name, scopes } = twitchRoot.toJSON(); - - if ( valid !== true || !user_name || !this._validateScope( scopes ) ) { + try { + // noinspection JSValidateTypes + return await this.store.queryRecord( "twitch-user", {} ); + } catch ( e ) { throw new Error( "Invalid session" ); } + }, - return twitchRoot; + /** + * Check the scopes on the stored Auth record before attempting a login. + * Scopes can always be updated/extended in the future, requiring a new authentication. + */ + _validateSessionScope() { + const { scope } = this.session; + if ( !scope || !this._validateScope( scope.split( SEP_SCOPES_STORAGE ) ) ) { + throw new Error( "Invalid access token scopes" ); + } }, /** * Received and expected scopes need to be identical - * @param {string[]} returnedScopes + * @param {string[]} scopes * @returns {boolean} */ - _validateScope( returnedScopes ) { - return Array.isArray( returnedScopes ) - && expectedScopes.every( item => returnedScopes.includes( item ) ); + _validateScope( scopes ) { + return Array.isArray( scopes ) && expectedScopes.every( item => scopes.includes( item ) ); }, /** * Update the auth record and save it * @param {string} access_token - * @param {TwitchRoot} twitchRoot * @returns {Promise} */ - async _sessionSave( access_token, twitchRoot ) { + async _sessionSave( access_token ) { const { session } = this; - const scope = twitchRoot.scopes.content.join( "+" ); + const scope = expectedScopes.join( SEP_SCOPES_STORAGE ); const date = new Date(); setProperties( session, { access_token, scope, date } ); diff --git a/src/app/services/chat/providers/-basic.js b/src/app/services/chat/providers/-basic.js index 878f96d48e..91b755aa7f 100644 --- a/src/app/services/chat/providers/-basic.js +++ b/src/app/services/chat/providers/-basic.js @@ -49,7 +49,7 @@ export default class ChatProviderBasic extends ChatProvider { ]; } - _getRuntimeContext( { name: channel }, session ) { + _getRuntimeContext( channel, session ) { const { user_name: user, access_token: token, isLoggedIn } = session; const url = this._getUrl( channel ); diff --git a/src/app/services/chat/providers/-provider.js b/src/app/services/chat/providers/-provider.js index 153a96490f..242e9eea9f 100644 --- a/src/app/services/chat/providers/-provider.js +++ b/src/app/services/chat/providers/-provider.js @@ -32,16 +32,15 @@ export default class ChatProvider { /** * Launch the provider process - * @param {Object} channel - * @param {string} channel.name + * @param {string} login * @param {Object} [session] * @param {string} [session.access_token] * @param {string} [session.user_name] * @param {boolean} [session.isLoggedIn] * @returns {Promise} */ - async launch( channel, session = {} ) { - const context = this._getRuntimeContext( channel, session ); + async launch( login, session = {} ) { + const context = this._getRuntimeContext( login, session ); const params = getParameters( context, this.parameters ); await launch( this.exec, params ); @@ -97,8 +96,7 @@ export default class ChatProvider { /* istanbul ignore next */ /** * Get the runtime context object - * @param {Object} channel - * @param {string} channel.name + * @param {string} login * @param {Object} session * @param {string} [session.access_token] * @param {string} [session.user_name] @@ -106,7 +104,7 @@ export default class ChatProvider { * @returns {Object} */ // eslint-disable-next-line no-unused-vars - _getRuntimeContext( channel, session ) { + _getRuntimeContext( login, session ) { return this.context; } diff --git a/src/app/services/chat/providers/browser.js b/src/app/services/chat/providers/browser.js index 018d6305a3..7d8f8fc9e7 100644 --- a/src/app/services/chat/providers/browser.js +++ b/src/app/services/chat/providers/browser.js @@ -16,7 +16,7 @@ export default class ChatProviderBrowser extends ChatProvider { } // noinspection JSCheckFunctionSignatures - async launch({ name: channel }) { + async launch( channel ) { const url = this._getUrl( channel ); openExternal( url ); } diff --git a/src/app/services/chat/providers/chatterino.js b/src/app/services/chat/providers/chatterino.js index a906cfc21f..aa2476976b 100644 --- a/src/app/services/chat/providers/chatterino.js +++ b/src/app/services/chat/providers/chatterino.js @@ -15,7 +15,7 @@ export default class ChatProviderChatterino extends ChatProviderBasic { } // noinspection JSCheckFunctionSignatures - _getRuntimeContext({ name: channel }) { + _getRuntimeContext( channel ) { return Object.assign( {}, this.context, { channel: `t:${channel}` }); diff --git a/src/app/services/chat/service.js b/src/app/services/chat/service.js index 4cc6df289e..7150639d7f 100644 --- a/src/app/services/chat/service.js +++ b/src/app/services/chat/service.js @@ -27,22 +27,24 @@ export default Service.extend({ }); }), - async openChat( twitchChannel ) { - /** @type {{name: string}} */ - const channelData = twitchChannel.toJSON(); + /** + * @param {string} login + * @return {Promise} + */ + async openChat( login ) { const session = get( this, "auth.session" ); /** @type {Object} */ const sessionData = getProperties( session, "access_token", "user_name", "isLoggedIn" ); await logDebug( "Preparing to launch chat", { - channel: channelData.name, + channel: login, user: sessionData.user_name }); try { /** @type {ChatProvider} */ const provider = await this._getChatProvider(); - await provider.launch( channelData, sessionData ); + await provider.launch( login, sessionData ); } catch ( error ) { await logError( error ); diff --git a/src/app/services/notification/badge.js b/src/app/services/notification/badge.js index 442bd4fcb3..2a17655bb8 100644 --- a/src/app/services/notification/badge.js +++ b/src/app/services/notification/badge.js @@ -1,34 +1,30 @@ -import { get, observer } from "@ember/object"; +import { observer } from "@ember/object"; import { and } from "@ember/object/computed"; import { default as Evented, on } from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; import { inject as service } from "@ember/service"; -import nwWindow from "nwjs/Window"; export default Mixin.create( Evented, { + /** @type {NwjsService} */ + nwjs: service(), + /** @type {SettingsService} */ settings: service(), // will be overridden by NotificationService running: false, - _badgeEnabled: and( "running", "settings.notification.badgelabel" ), + _badgeEnabled: and( "running", "settings.content.notification.badgelabel" ), _badgeEnabledObserver: observer( "_badgeEnabled", function() { - if ( !get( this, "_badgeEnabled" ) ) { - this.badgeSetLabel( "" ); + if ( !this._badgeEnabled ) { + this.nwjs.setBadgeLabel( "" ); } }), _badgeStreamsAllListener: on( "streams-all", function( streams ) { - if ( streams && get( this, "_badgeEnabled" ) ) { - const length = get( streams, "length" ); - this.badgeSetLabel( String( length ) ); + if ( streams && this._badgeEnabled ) { + this.nwjs.setBadgeLabel( `${streams.length}` ); } - }), - - badgeSetLabel( label ) { - // update badge label or remove it - nwWindow.setBadgeLabel( label ); - } + }) }); diff --git a/src/app/services/notification/cache/index.js b/src/app/services/notification/cache/index.js index 2091d80ba0..a45ca507e3 100644 --- a/src/app/services/notification/cache/index.js +++ b/src/app/services/notification/cache/index.js @@ -29,6 +29,8 @@ export function cacheAdd( stream ) { * @returns {TwitchStream[]} */ export function cacheFill( streams, firstRun ) { + streams = streams.slice(); + // figure out which streams are new for ( let item, idx, i = 0, l = cache.length; i < l; i++ ) { item = cache[ i ]; diff --git a/src/app/services/notification/cache/item.js b/src/app/services/notification/cache/item.js index 7f5135de52..6dde80105c 100644 --- a/src/app/services/notification/cache/item.js +++ b/src/app/services/notification/cache/item.js @@ -1,13 +1,10 @@ -import { get } from "@ember/object"; - - export default class NotificationStreamCacheItem { /** * @param {TwitchStream} stream */ constructor( stream ) { - this.id = get( stream, "id" ); - this.since = get( stream, "created_at" ); + this.id = stream.id; + this.since = stream.started_at; this.fails = 0; } @@ -16,8 +13,8 @@ export default class NotificationStreamCacheItem { * @returns {Number} */ findStreamIndex( streams ) { - for ( let id = this.id, i = 0, l = get( streams, "length" ); i < l; i++ ) { - if ( get( streams[ i ], "id" ) === id ) { + for ( let id = this.id, i = 0, l = streams.length; i < l; i++ ) { + if ( streams[ i ].id === id ) { return i; } } @@ -29,6 +26,6 @@ export default class NotificationStreamCacheItem { * @returns {Boolean} */ isNotNewer( stream ) { - return this.since >= get( stream, "created_at" ); + return this.since >= stream.started_at; } } diff --git a/src/app/services/notification/dispatch.js b/src/app/services/notification/dispatch.js index 2f4b76969d..e42fbb0e8b 100644 --- a/src/app/services/notification/dispatch.js +++ b/src/app/services/notification/dispatch.js @@ -21,7 +21,9 @@ export default Mixin.create( Evented, { intl: service(), /** @type {RouterService} */ router: service(), + /** @type {SettingsService} */ settings: service(), + /** @type {StreamingService} */ streaming: service(), @@ -33,9 +35,13 @@ export default Mixin.create( Evented, { */ async function( streams ) { if ( !streams ) { return; } - const length = get( streams, "length" ); + const { length } = streams; - if ( length > 1 && get( this, "settings.notification.grouping" ) ) { + // load TwitchUser relationships first and work around the missing user_name attribute + // https://github.com/twitchdev/issues/issues/500 + await Promise.all( streams.map( stream => stream.user.promise ) ); + + if ( length > 1 && this.settings.content.notification.grouping ) { // merge multiple notifications and show a single one const data = this._getNotificationDataGroup( streams ); await this._showNotification( data ); @@ -43,9 +49,9 @@ export default Mixin.create( Evented, { } else if ( length > 0 ) { await Promise.all( streams.map( async stream => { // download channel icon first and save it into a local temp dir... - await iconDownload( stream ); + const icon = await iconDownload( stream.user.content ); // show notification - const data = this._getNotificationDataSingle( stream ); + const data = this._getNotificationDataSingle( stream, icon ); await this._showNotification( data ); }) ); } @@ -58,13 +64,13 @@ export default Mixin.create( Evented, { * @returns {NotificationData} */ _getNotificationDataGroup( streams ) { - const settings = get( this, "settings.notification.click_group" ); + const settings = this.settings.content.notification.click_group; return new NotificationData({ title: this.intl.t( "services.notification.dispatch.group" ).toString(), message: streams.map( stream => ({ - title: get( stream, "channel.display_name" ), - message: get( stream, "channel.status" ) || "" + title: get( stream, "user.display_name" ), + message: stream.title || "" }) ), icon: iconGroup, click: () => this._notificationClick( streams, settings ), @@ -75,16 +81,17 @@ export default Mixin.create( Evented, { /** * Show a notification for each stream * @param {TwitchStream} stream + * @param {string} icon * @returns {NotificationData} */ - _getNotificationDataSingle( stream ) { - const settings = get( this, "settings.notification.click" ); - const name = get( stream, "channel.display_name" ); + _getNotificationDataSingle( stream, icon ) { + const settings = this.settings.content.notification.click; + const name = get( stream, "user.display_name" ); return new NotificationData({ title: this.intl.t( "services.notification.dispatch.single", { name } ).toString(), - message: get( stream, "channel.status" ) || "", - icon: get( stream, "logo" ) || iconGroup, + message: stream.title || "", + icon: icon || iconGroup, click: () => this._notificationClick( [ stream ], settings ), settings }); @@ -101,13 +108,13 @@ export default Mixin.create( Evented, { return; } - logDebug( "Notification click", () => ({ + await logDebug( "Notification click", () => ({ action, streams: streams.mapBy( "id" ) }) ); // restore the window - if ( get( this, "settings.notification.click_restore" ) ) { + if ( this.settings.content.notification.click_restore ) { setMinimized( false ); setVisibility( true ); setFocused( true ); @@ -117,7 +124,7 @@ export default Mixin.create( Evented, { this.router.transitionTo( "user.followedStreams" ); } else if ( action === ATTR_NOTIFY_CLICK_STREAM ) { - const streaming = get( this, "streaming" ); + const { streaming } = this; await Promise.all( streams.map( async stream => { // don't await startStream promise and ignore errors streaming.startStream( stream ) @@ -125,13 +132,11 @@ export default Mixin.create( Evented, { }) ); } else if ( action === ATTR_NOTIFY_CLICK_STREAMANDCHAT ) { - const streaming = get( this, "streaming" ); - const openGlobal = get( this, "settings.streams.chat_open" ); - const chat = get( this, "chat" ); + const { streaming, chat } = this; + const openGlobal = this.settings.content.streams.chat_open; await Promise.all( streams.map( async stream => { - const channel = get( stream, "channel" ); - const { streams_chat_open: openChannel } = await channel.getChannelSettings(); + const { streams_chat_open: openChannel } = await stream.getChannelSettings(); // don't await startStream promise and ignore errors streaming.startStream( stream ) @@ -150,7 +155,7 @@ export default Mixin.create( Evented, { return; } // don't await openChat promise and ignore errors - chat.openChat( channel ) + chat.openChat( get( stream, "user.login" ) ) .catch( () => {} ); }) ); } @@ -161,7 +166,7 @@ export default Mixin.create( Evented, { * @returns {Promise} */ async _showNotification( data ) { - const provider = get( this, "settings.notification.provider" ); + const provider = this.settings.content.notification.provider; // don't await the notification promise here showNotification( provider, data, false ) diff --git a/src/app/services/notification/icons.js b/src/app/services/notification/icons.js index 59b019f7a7..b7fb6eab9c 100644 --- a/src/app/services/notification/icons.js +++ b/src/app/services/notification/icons.js @@ -1,4 +1,3 @@ -import { get, set } from "@ember/object"; import { files as filesConfig, notification as notificationConfig } from "config"; import { cachedir } from "utils/node/platform"; import mkdirp from "utils/node/fs/mkdirp"; @@ -16,6 +15,9 @@ const { } = notificationConfig; const iconCacheDir = join( cachedir, cacheName ); +/** @type {Map} */ +const userIconCache = new Map(); + // TODO: implement an icon resolver for Linux icon themes export const iconGroup = resolve( bigIcon ); @@ -37,18 +39,18 @@ export async function iconDirClear() { } /** - * @param {TwitchStream} stream - * @returns {Promise} + * @param {TwitchUser} user + * @returns {Promise} */ -export async function iconDownload( stream ) { +export async function iconDownload( user ) { // don't download logo again if it has already been downloaded - if ( get( stream, "logo" ) ) { - return; - } + const { id, profile_image_url } = user; - const logo = get( stream, "channel.logo" ); - const file = await download( logo, iconCacheDir ); + let file = userIconCache.get( id ); + if ( !file ) { + file = await download( profile_image_url, iconCacheDir ); + userIconCache.set( id, file ); + } - // set the local channel logo on the twitchStream record - set( stream, "logo", file ); + return file; } diff --git a/src/app/services/notification/polling.js b/src/app/services/notification/polling.js index c06b0000f7..77d47c7a48 100644 --- a/src/app/services/notification/polling.js +++ b/src/app/services/notification/polling.js @@ -1,13 +1,13 @@ import { A } from "@ember/array"; -import { get, set, setProperties, observer } from "@ember/object"; +import { set, setProperties, observer } from "@ember/object"; import Evented from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; -import { cancel, later } from "@ember/runloop"; import { inject as service } from "@ember/service"; import { notification as notificationConfig } from "config"; import { cacheClear, cacheFill } from "./cache"; import { iconDirCreate, iconDirClear } from "./icons"; import { logError } from "./logger"; +import { setTimeout, clearTimeout } from "timers"; const { @@ -20,13 +20,19 @@ const { error: intervalError }, query: { - limit + first, + maxQueries } } = notificationConfig; -export default Mixin.create( Evented, { +// TODO: rewrite this as a generic PollingService with a better design and better tests +export default Mixin.create( Evented, /** @class NotificatonServicePollingMixin */ { + /** @type {AuthService} */ + auth: service(), + /** @type {SettingsService} */ settings: service(), + /** @type {DS.Store} */ store: service(), // NotificationService properties @@ -36,12 +42,12 @@ export default Mixin.create( Evented, { // state _pollNext: null, _pollTries: 0, - _pollInitializedPromise: null, + _pollInitialized: false, _pollPromise: null, _pollObserver: observer( "running", function() { - if ( get( this, "running" ) ) { + if ( this.running ) { this._pollPromise = this.start(); } else { this.reset(); @@ -52,7 +58,7 @@ export default Mixin.create( Evented, { reset() { // unqueue if ( this._pollNext ) { - cancel( this._pollNext ); + clearTimeout( this._pollNext ); } // reset state @@ -70,18 +76,17 @@ export default Mixin.create( Evented, { this.reset(); // wait for initialization to complete - if ( !this._pollInitializedPromise ) { - this._pollInitializedPromise = Promise.resolve() - .then( iconDirCreate ) - .then( iconDirClear ); + if ( !this._pollInitialized ) { + await iconDirCreate(); + await iconDirClear(); + this._pollInitialized = true; } - await this._pollInitializedPromise; // start polling await this._poll( true ); } catch ( e ) { - logError( e ); + await logError( e ); } }, @@ -90,7 +95,7 @@ export default Mixin.create( Evented, { * @returns {Promise} */ async _poll( firstRun ) { - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } let streams; try { @@ -101,7 +106,7 @@ export default Mixin.create( Evented, { return this._pollFailure(); } - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } await this._pollResult( streams, firstRun ); // requeue @@ -117,7 +122,7 @@ export default Mixin.create( Evented, { }, _pollFailure() { - let tries = get( this, "_pollTries" ); + let tries = this._pollTries; // did we reach the retry limit yet? if ( ++tries > failsRequests ) { @@ -136,37 +141,41 @@ export default Mixin.create( Evented, { }, _pollRequeue( time ) { - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } this._pollPromise = new Promise( resolve => - set( this, "_pollNext", later( resolve, time ) ) + set( this, "_pollNext", setTimeout( resolve, time ) ) ) .then( () => this._poll( false ) ) .catch( logError ); }, /** - * @returns {Promise.} + * @returns {Promise} */ async _pollQuery() { - const store = get( this, "store" ); + const { store } = this; const allStreams = A(); + const params = { + user_id: this.auth.session.user_id, + first + }; + let num = 0; // eslint-disable-next-line no-constant-condition - while ( true ) { - const streams = await store.query( "twitchStreamFollowed", { - offset: get( allStreams, "length" ), - limit - }); + while ( ++num <= maxQueries ) { + const streams = await store.query( "twitch-stream-followed", params ); // add new streams to the overall streams list allStreams.push( ...streams.mapBy( "stream" ) ); // stop querying as soon as a request doesn't have enough items // otherwise query again with an increased offset - if ( get( streams, "length" ) < limit ) { + if ( streams.length < first || !streams.meta.pagination ) { break; } + + params.after = streams.meta.pagination.cursor; } // remove any potential duplicates that may have occured between multiple requests @@ -191,25 +200,25 @@ export default Mixin.create( Evented, { this.trigger( "streams-filtered", filteredStreams ); } catch ( e ) { - logError( e ); + await logError( e ); } }, /** * @param {TwitchStream[]} streams - * @returns {Promise.} + * @returns {Promise} */ async _filterStreams( streams ) { - const filter = get( this, "settings.notification.filter" ); - const filter_vodcasts = get( this, "settings.notification.filter_vodcasts" ); + const filter = this.settings.content.notification.filter; // filter vodcasts before loading channel settings - streams = streams.filter( stream => filter_vodcasts ? !get( stream, "isVodcast" ) : true ); + if ( this.settings.content.notification.filter_vodcasts ) { + streams = streams.filter( stream => !stream.isVodcast ); + } // get a list of all streams and their channel's individual settings const streamSettingsObjects = await Promise.all( streams.map( async stream => { - const channel = get( stream, "channel" ); - const { notification_enabled } = await channel.getChannelSettings(); + const { notification_enabled } = await stream.getChannelSettings(); return { stream, notification_enabled }; }) ); diff --git a/src/app/services/notification/service.js b/src/app/services/notification/service.js index efc448bdf8..e9d1080914 100644 --- a/src/app/services/notification/service.js +++ b/src/app/services/notification/service.js @@ -12,16 +12,19 @@ export default Service.extend( NotificationServiceDispatchMixin, NotificationServiceBadgeMixin, NotificationServiceTrayMixin, + /** @class NotificationService */ { + /** @type {AuthService} */ auth: service(), /** @type {IntlService} */ intl: service(), + /** @type {SettingsService} */ settings: service(), error: false, paused: false, - enabled: and( "auth.session.isLoggedIn", "settings.notification.enabled" ), + enabled: and( "auth.session.isLoggedIn", "settings.content.notification.enabled" ), running: computed( "enabled", "paused", function() { return get( this, "enabled" ) && !get( this, "paused" ); diff --git a/src/app/services/nwjs.js b/src/app/services/nwjs.js index 9c15801118..b8da30c4ba 100644 --- a/src/app/services/nwjs.js +++ b/src/app/services/nwjs.js @@ -113,6 +113,13 @@ export default Service.extend( /** @class NwjsService */ { } }, + /** + * @param {string?} label + */ + setBadgeLabel( label = "" ) { + nwWindow.setBadgeLabel( `${label}` ); + }, + /** * @param {MouseEvent} event * @param {nw.MenuItem[]} items diff --git a/src/app/services/streaming/player/substitutions.js b/src/app/services/streaming/player/substitutions.js index 73123c937e..cc923ba25a 100644 --- a/src/app/services/streaming/player/substitutions.js +++ b/src/app/services/streaming/player/substitutions.js @@ -6,37 +6,37 @@ import t from "translation-key"; export default [ new Substitution( [ "name", "channel", "channelname" ], - "stream.channel.display_name", + "stream.stream.user_name", t`settings.player.args.substitutions.channel` ), new Substitution( [ "status", "title" ], - "stream.channel.status", + "stream.stream.title", t`settings.player.args.substitutions.status` ), new Substitution( [ "game", "gamename" ], - "stream.stream.game", + "stream.stream.game_name", t`settings.player.args.substitutions.game` ), new Substitution( "delay", - "stream.stream.delay", + "stream.stream.channel.delay", t`settings.player.args.substitutions.delay` ), new Substitution( [ "online", "since", "created" ], - "stream.stream.created_at", + "stream.stream.started_at", t`settings.player.args.substitutions.created` ), new Substitution( [ "viewers", "current" ], - "stream.stream.viewers", + "stream.stream.viewer_count", t`settings.player.args.substitutions.viewers` ), new Substitution( [ "views", "overall" ], - "stream.channel.views", + "stream.user.view_count", t`settings.player.args.substitutions.views` ) ]; diff --git a/src/app/services/streaming/service.js b/src/app/services/streaming/service.js index 12af5dfc89..8a179db976 100644 --- a/src/app/services/streaming/service.js +++ b/src/app/services/streaming/service.js @@ -82,8 +82,12 @@ export default Service.extend( /** @class StreamingService */ { */ async startStream( twitchStream, quality ) { const { /** @type {DS.Store} */ store } = this; - const { /** @type {TwitchChannel} */ channel } = twitchStream; - const { name: id } = channel; + + await twitchStream.user.promise; + /** @type {TwitchUser} */ + const user = twitchStream.user.content; + + const { id } = user; /** @type {Stream} */ let stream; @@ -103,7 +107,7 @@ export default Service.extend( /** @class StreamingService */ { // create a new Stream record stream = store.createRecord( "stream", { id, - channel, + user, stream: twitchStream, quality: this.settings.content.streaming.quality, low_latency: this.settings.content.streaming.low_latency, @@ -202,7 +206,7 @@ export default Service.extend( /** @class StreamingService */ { || !this.settings.content.streams.chat_open_context ) ) { - this.chat.openChat( stream.channel ) + this.chat.openChat( stream.stream.user_login ) .catch( () => {} ); } @@ -258,8 +262,7 @@ export default Service.extend( /** @class StreamingService */ { * @return {Promise} */ async getChannelSettings( stream, quality ) { - const { channel } = stream; - const channelSettings = await channel.getChannelSettings(); + const channelSettings = await stream.stream.getChannelSettings(); // override channel specific settings if ( quality === undefined ) { diff --git a/src/app/ui/components/button/channel-button/component.js b/src/app/ui/components/button/channel-button/component.js index 285c755efe..0fb1718406 100644 --- a/src/app/ui/components/button/channel-button/component.js +++ b/src/app/ui/components/button/channel-button/component.js @@ -2,21 +2,28 @@ import { computed } from "@ember/object"; import { inject as service } from "@ember/service"; import FormButtonComponent from "../form-button/component"; import HotkeyMixin from "ui/components/-mixins/hotkey"; +import { twitch as twitchConfig } from "config"; import "./styles.less"; +const { "channel-url": channelUrl } = twitchConfig; + + export default FormButtonComponent.extend( HotkeyMixin, { /** @type {IntlService} */ intl: service(), /** @type {NwjsService} */ nwjs: service(), + /** @type {TwitchUser} */ + user: null, + classNames: [ "channel-button-component", "btn-primary" ], icon: "fa-twitch", iconanim: true, - _title: computed( "intl.locale", "channel.display_name", function() { - const { display_name: name } = this.channel; + _title: computed( "intl.locale", "user.display_name", function() { + const { display_name: name } = this.user; return this.intl.t( "components.channel-button.title", { name } ); }), @@ -30,7 +37,7 @@ export default FormButtonComponent.extend( HotkeyMixin, { async action( success, failure ) { try { - this.nwjs.openBrowser( this.channel.url ); + this.nwjs.openBrowser( channelUrl, { channel: this.user.login } ); await success(); } catch ( err ) { await failure( err ); diff --git a/src/app/ui/components/button/open-chat/component.js b/src/app/ui/components/button/open-chat/component.js index e88152a9a1..16b9201807 100644 --- a/src/app/ui/components/button/open-chat/component.js +++ b/src/app/ui/components/button/open-chat/component.js @@ -1,4 +1,3 @@ -import { get } from "@ember/object"; import { inject as service } from "@ember/service"; import { t } from "ember-intl"; import FormButtonComponent from "../form-button/component"; @@ -8,9 +7,13 @@ import HotkeyMixin from "ui/components/-mixins/hotkey"; export default FormButtonComponent.extend( HotkeyMixin, { /** @type {IntlService} */ intl: service(), + /** @type {ChatService} */ chat: service(), - classNames: [ "btn-hint" ], + /** @type {TwitchUser} */ + user: null, + + classNames: [ "open-chat-component", "btn-hint" ], icon: "fa-comments", _title: t( "components.open-chat.title" ), iconanim: true, @@ -23,9 +26,6 @@ export default FormButtonComponent.extend( HotkeyMixin, { }, action() { - const channel = get( this, "channel" ); - const chat = get( this, "chat" ); - - return chat.openChat( channel ); + return this.chat.openChat( this.user.login ); } }); diff --git a/src/app/ui/components/button/share-channel/component.js b/src/app/ui/components/button/share-channel/component.js index 5c03df63f8..fca41f2156 100644 --- a/src/app/ui/components/button/share-channel/component.js +++ b/src/app/ui/components/button/share-channel/component.js @@ -2,6 +2,10 @@ import { inject as service } from "@ember/service"; import { t } from "ember-intl"; import FormButtonComponent from "../form-button/component"; import HotkeyMixin from "ui/components/-mixins/hotkey"; +import { twitch as twitchConfig } from "config"; + + +const { "channel-url": channelUrl } = twitchConfig; export default FormButtonComponent.extend( HotkeyMixin, { @@ -10,7 +14,10 @@ export default FormButtonComponent.extend( HotkeyMixin, { /** @type {NwjsService} */ nwjs: service(), - classNames: [ "btn-info" ], + /** @type {TwitchUser} */ + user: null, + + classNames: [ "share-channel-component", "btn-info" ], icon: "fa-share-alt", _title: t( "components.share-channel.title" ), iconanim: true, @@ -24,7 +31,8 @@ export default FormButtonComponent.extend( HotkeyMixin, { async action( success, failure ) { try { - this.nwjs.clipboard.set( this.channel.url ); + const url = channelUrl.replace( "{channel}", this.user.login ); + this.nwjs.clipboard.set( url ); await success(); } catch ( err ) { await failure( err ); diff --git a/src/app/ui/components/button/twitch-emotes/component.js b/src/app/ui/components/button/twitch-emotes/component.js index f60fe9af58..d9e42c0b6a 100644 --- a/src/app/ui/components/button/twitch-emotes/component.js +++ b/src/app/ui/components/button/twitch-emotes/component.js @@ -1,12 +1,12 @@ import { and, or } from "@ember/object/computed"; import { inject as service } from "@ember/service"; import { t } from "ember-intl"; -import { twitch } from "config"; +import { twitch as twitchConfig } from "config"; import FormButtonComponent from "../form-button/component"; import HotkeyMixin from "ui/components/-mixins/hotkey"; -const { "emotes-url": twitchEmotesUrl } = twitch; +const { "emotes-url": twitchEmotesUrl } = twitchConfig; export default FormButtonComponent.extend( HotkeyMixin, { @@ -14,13 +14,18 @@ export default FormButtonComponent.extend( HotkeyMixin, { intl: service(), /** @type {NwjsService} */ nwjs: service(), + /** @type {SettingsService} */ settings: service(), + /** @type {TwitchUser} */ + user: null, + /** @type {boolean} */ showButton: false, - isEnabled: or( "showButton", "settings.streams.twitchemotes" ), - isVisible: and( "isEnabled", "channel.partner" ), - classNames: [ "btn-neutral" ], + isEnabled: or( "showButton", "settings.content.streams.twitchemotes" ), + isVisible: and( "isEnabled", "user.isPartner" ), + + classNames: [ "twitch-emotes-component", "btn-neutral" ], icon: "fa-smile-o", iconanim: true, _title: t( "components.twitch-emotes.title" ), @@ -34,7 +39,7 @@ export default FormButtonComponent.extend( HotkeyMixin, { async action( success, failure ) { try { - const { id } = this.channel; + const { id } = this.user; this.nwjs.openBrowser( twitchEmotesUrl, { id } ); await success(); } catch ( err ) { diff --git a/src/app/ui/components/list/channel-item/styles.less b/src/app/ui/components/list/channel-item/styles.less index 741d061f77..8061f4874a 100644 --- a/src/app/ui/components/list/channel-item/styles.less +++ b/src/app/ui/components/list/channel-item/styles.less @@ -31,10 +31,6 @@ margin: 0 0 .5em; } - > .game { - margin: 0 0 .25em; - } - > .status { height: unit( @line-height-base, em ); } diff --git a/src/app/ui/components/list/channel-item/template.hbs b/src/app/ui/components/list/channel-item/template.hbs index a6b3ffdfd8..5443971aba 100644 --- a/src/app/ui/components/list/channel-item/template.hbs +++ b/src/app/ui/components/list/channel-item/template.hbs @@ -1,11 +1,10 @@ {{#link-to "channel" content.id tagName="div" class="logo"}} -{{preview-image src=content.logo title=content.display_name}} + {{preview-image src=content.profile_image_url title=content.display_name}} {{/link-to}}
-
-{{#if content.hasLanguage}}{{flag-icon type="channel" lang=content.language}}{{/if}} -{{#link-to "channel" content.id}}{{content.detailedName}}{{/link-to}} -
-
{{#if content.game}}{{#link-to "games.game" content.game}}{{content.game}}{{/link-to}}{{else}}{{t "components.channel-item.game-missing"}}{{/if}}
-{{#if content.status}}{{embedded-links tagName="div" class="status" text=content.status}}{{/if}} +
+ {{#if content.channel.broadcaster_language}}{{flag-icon type="broadcaster" lang=content.channel.broadcaster_language}}{{/if}} + {{#link-to "channel" content.id}}{{content.detailedName}}{{/link-to}} +
+ {{embedded-links tagName="div" class="status" text=content.description}}
\ No newline at end of file diff --git a/src/app/ui/components/list/game-item/component.js b/src/app/ui/components/list/game-item/component.js index 2075d0c1bd..d51c1602b9 100644 --- a/src/app/ui/components/list/game-item/component.js +++ b/src/app/ui/components/list/game-item/component.js @@ -1,4 +1,3 @@ -import { alias, or } from "@ember/object/computed"; import { inject as service } from "@ember/service"; import ListItemComponent from "../-list-item/component"; import layout from "./template.hbs"; @@ -13,10 +12,7 @@ export default ListItemComponent.extend({ classNames: [ "game-item-component" ], - game: alias( "content.game" ), - hasStats: or( "content.channels", "content.viewers" ), - click() { - this.router.transitionTo( "games.game", this.game.name ); + this.router.transitionTo( "games.game", this.content.id ); } }); diff --git a/src/app/ui/components/list/game-item/template.hbs b/src/app/ui/components/list/game-item/template.hbs index 0b90ff452e..a21004e1a1 100644 --- a/src/app/ui/components/list/game-item/template.hbs +++ b/src/app/ui/components/list/game-item/template.hbs @@ -1,8 +1,2 @@ -{{preview-image src=game.box.large}} -
{{game.name}}
-{{#if hasStats}} -
- {{content.channels}} -{{format-viewers content.viewers}} -
-{{/if}} \ No newline at end of file +{{preview-image src=content.box_art_url.latest}} +
{{content.name}}
\ No newline at end of file diff --git a/src/app/ui/components/list/settings-channel-item/template.hbs b/src/app/ui/components/list/settings-channel-item/template.hbs index 977f481680..58ec2cffbd 100644 --- a/src/app/ui/components/list/settings-channel-item/template.hbs +++ b/src/app/ui/components/list/settings-channel-item/template.hbs @@ -1,27 +1,30 @@ {{#unless content.isFulfilled}} -{{loading-spinner}} + {{loading-spinner}} {{else}} -{{#link-to "channel" content.id tagName="div" class="logo"}} -{{preview-image src=content.logo title=content.display_name}} -{{/link-to}} -
-
-{{#if content.hasLanguage}}{{flag-icon type="channel" lang=content.language}}{{/if}} -{{#link-to "channel" content.id}}{{content.display_name}}{{/link-to}} -
-{{#unless dialog}} -
-

{{#link-to "channel.settings" content.id}}{{t "components.settings-channel-item.settings"}}{{/link-to}}

-

{{t "components.settings-channel-item.erase"}}

-
-{{else}} -
-

{{t "components.settings-channel-item.confirmation"}}

-
-{{#form-button action=(action "confirm") classNames="btn-success"}}{{t "components.settings-channel-item.confirm"}}{{/form-button}} -{{#form-button action=(action "decline") classNames="btn-danger"}}{{t "components.settings-channel-item.decline"}}{{/form-button}} -
-
-{{/unless}} -
+ {{#link-to "channel" content.id tagName="div" class="logo"}} + {{preview-image + src=content.profile_image_url + title=content.display_name + }} + {{/link-to}} +
+
+ {{#if content.channel.broadcaster_language}}{{flag-icon type="broadcaster" lang=content.channel.broadcaster_language}}{{/if}} + {{#link-to "channel" content.id}}{{content.display_name}}{{/link-to}} +
+ {{#unless dialog}} +
+

{{#link-to "channel.settings" content.id}}{{t "components.settings-channel-item.settings"}}{{/link-to}}

+

{{t "components.settings-channel-item.erase"}}

+
+ {{else}} +
+

{{t "components.settings-channel-item.confirmation"}}

+
+ {{#form-button action=(action "confirm") classNames="btn-success"}}{{t "components.settings-channel-item.confirm"}}{{/form-button}} + {{#form-button action=(action "decline") classNames="btn-danger"}}{{t "components.settings-channel-item.decline"}}{{/form-button}} +
+
+ {{/unless}} +
{{/unless}} \ No newline at end of file diff --git a/src/app/ui/components/list/stream-item/component.js b/src/app/ui/components/list/stream-item/component.js index ea0ea603e7..d17d3cb31d 100644 --- a/src/app/ui/components/list/stream-item/component.js +++ b/src/app/ui/components/list/stream-item/component.js @@ -25,6 +25,8 @@ export default ListItemComponent.extend({ "expanded:expanded" ], + /** @type {TwitchUser} */ + user: alias( "content.user" ), /** @type {TwitchChannel} */ channel: alias( "content.channel" ), @@ -32,7 +34,7 @@ export default ListItemComponent.extend({ locked : false, timer : null, - showGame: notEmpty( "channel.game" ), + showGame: notEmpty( "content.game_id" ), infoGame: equal( "settings.content.streams.info", ATTR_STREAMS_INFO_GAME ), infoTitle: equal( "settings.content.streams.info", ATTR_STREAMS_INFO_TITLE ), @@ -59,19 +61,21 @@ export default ListItemComponent.extend({ faded: computed( "fading_enabled", "settings.content.streams.languages", - "channel.language", - "channel.broadcaster_language", + "content.language", + "content.channel.broadcaster_language", function() { if ( !this.fading_enabled ) { return false; } - const { languages } = this.settings.content.streams; - const langs = languages.toJSON(); - const { language: clang, broadcaster_language: blang } = this.channel; + const { language: clang } = this.content; // a channel language needs to be set if ( clang ) { + const { languages } = this.settings.content.streams; + const langs = languages.toJSON(); + const blang = get( this, "content.channel.broadcaster_language" ); + // fade out if // no broadcaster language is set and channel language is filtered out if ( !blang && !langs[ clang ] ) { diff --git a/src/app/ui/components/list/stream-item/template.hbs b/src/app/ui/components/list/stream-item/template.hbs index a20c029bbd..82f97eb257 100644 --- a/src/app/ui/components/list/stream-item/template.hbs +++ b/src/app/ui/components/list/stream-item/template.hbs @@ -1,43 +1,41 @@
-{{#if channel.hasLanguage}}{{flag-icon type="channel" lang=channel.language}}{{/if}} -{{#link-to "channel" channel.id}}{{channel.detailedName}}{{/link-to}} -{{#if channel.hasBroadcasterLanguage}}{{flag-icon type="broadcaster" lang=channel.broadcaster_language}}{{/if}} + {{#if content.hasLanguage}}{{flag-icon type="channel" lang=content.language}}{{/if}} + {{#link-to "channel" content.id}}{{content.user.detailedName}}{{/link-to}} + {{#if content.hasBroadcasterLanguage}}{{flag-icon type="broadcaster" lang=content.channel.broadcaster_language}}{{/if}}
{{#stream-preview-image - src=content.preview.medium + src=content.thumbnail_url.latest stream=content - channel=content.channel + user=content.user clickable=(bool-not expanded) tagName="section" class=(if expanded "expanded") - title=(unless expanded (unless infoTitle (if channel.status channel.status "") (if channel.game channel.game "")) "") + title=(unless expanded + (unless infoTitle content.title content.game_name) + "" + ) action=(action "collapse") }} -
-
-
{{#if channel.game}}{{#link-to "games.game" channel.game title=channel.game}}{{channel.game}}{{/link-to}}{{/if}}
-
{{channel.status}}
-
-
-
-{{#if content.hasFormatInfo}} -
-
{{content.resolution}}
-
{{t "components.stream-item.fps" fps=content.fps}}
-
-{{/if}} -
-{{embedded-links text=channel.status}} -
-
-
-
-
- -
+
+
+
{{#if content.game_id}}{{#link-to "games.game" content.game_id title=content.game_name}}{{content.game_name}}{{/link-to}}{{/if}}
+
{{content.title}}
+
+
+
+
+
+ {{embedded-links text=content.title}} +
+
+
+
+
+ +
{{/stream-preview-image}}
- {{hours-from-now content.created_at interval=60000}} -{{#if content.isVodcast}}{{/if}} -{{format-viewers content.viewers}} + {{hours-from-now content.started_at interval=60000}} + {{#if content.isVodcast}}{{/if}} + {{format-viewers content.viewer_count}}
\ No newline at end of file diff --git a/src/app/ui/components/list/team-item/template.hbs b/src/app/ui/components/list/team-item/template.hbs index ce330e992d..2ce67e3123 100644 --- a/src/app/ui/components/list/team-item/template.hbs +++ b/src/app/ui/components/list/team-item/template.hbs @@ -1,8 +1,8 @@ {{#link-to "team" content.id tagName="div" class="logo"}} -{{preview-image src=content.logo title=content.title}} + {{preview-image src=content.thumbnail_url title=content.title}} {{/link-to}}
-
{{#link-to "team" content.id}}{{content.title}}{{/link-to}}
-
{{t "components.team-item.created-at.format" created_at=content.created_at}}
-
{{t "components.team-item.updated-at.format" updated_at=content.updated_at}}
+
{{#link-to "team" content.id}}{{content.title}}{{/link-to}}
+
{{t "components.team-item.created-at.format" created_at=content.created_at}}
+
{{t "components.team-item.updated-at.format" updated_at=content.updated_at}}
\ No newline at end of file diff --git a/src/app/ui/components/main-menu/component.js b/src/app/ui/components/main-menu/component.js index 208310e0a5..4bf325455e 100644 --- a/src/app/ui/components/main-menu/component.js +++ b/src/app/ui/components/main-menu/component.js @@ -11,7 +11,6 @@ const hotkeyActionRouteMap = { "routeWatching": "watching", "routeUserAuth": "user.auth", "routeSettings": "settings", - "routeFeatured": "featured", "routeGames": "games", "routeStreams": "streams", "routeUserFollowedStreams": "user.followedStreams", diff --git a/src/app/ui/components/main-menu/template.hbs b/src/app/ui/components/main-menu/template.hbs index 639074d666..dbe2ea2f82 100644 --- a/src/app/ui/components/main-menu/template.hbs +++ b/src/app/ui/components/main-menu/template.hbs @@ -1,7 +1,6 @@